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
32 changed files with 237 additions and 3191 deletions

View File

@@ -6,7 +6,6 @@ import React, {
type CSSProperties,
useCallback,
} from 'react';
import { useFocusVisible } from 'react-aria';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -50,7 +49,6 @@ export function NotesButton({
const [tempNotes, setTempNotes] = useState<string>(note);
useEffect(() => setTempNotes(note), [note, id]);
const { isFocusVisible } = useFocusVisible();
const onOpenChange = useCallback<
NonNullable<ComponentProps<typeof Popover>['onOpenChange']>
>(
@@ -89,9 +87,7 @@ export function NotesButton({
...(isOpen && { color: theme.buttonNormalText }),
'&:hover': { opacity: 1 },
}),
!hasNotes && !isOpen && !isFocusVisible && !showPlaceholder
? 'hover-visible'
: '',
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
)}
data-placeholder={showPlaceholder}
onPress={() => {

View File

@@ -78,10 +78,10 @@ function PrivacyOverlay({ children, ...props }) {
display: 'inline-flex',
flexGrow: 1,
position: 'relative',
'> div:first-child': {
' > div:first-child': {
opacity: 0,
},
'> div:nth-child(2)': {
' > div:nth-child(2)': {
display: 'flex',
},
'&:hover': {

View File

@@ -11,7 +11,6 @@ import {
} from 'loot-core/types/models';
import { BudgetCategories } from './BudgetCategories';
import { BudgetCategories as BudgetCategoriesV2 } from './BudgetCategoriesV2';
import { BudgetSummaries } from './BudgetSummaries';
import { BudgetTotals } from './BudgetTotals';
import { type MonthBounds, MonthsProvider } from './MonthsContext';
@@ -25,7 +24,6 @@ import {
import { type DropPosition } from '@desktop-client/components/sort';
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
@@ -84,7 +82,6 @@ export function BudgetTable(props: BudgetTableProps) {
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
null,
);
const budgetTableV2Enabled = useFeatureFlag('budgetTableV2');
const onEditMonth = (id: string, month: string) => {
setEditing(id ? { id, cell: month } : null);
@@ -208,11 +205,15 @@ export function BudgetTable(props: BudgetTableProps) {
setShowHiddenCategoriesPef(!showHiddenCategories);
};
const onExpandAllCategories = () => {
const toggleHiddenCategories = () => {
onToggleHiddenCategories();
};
const expandAllCategories = () => {
onCollapse([]);
};
const onCollapseAllCategories = () => {
const collapseAllCategories = () => {
onCollapse(categoryGroups.map(g => g.id));
};
@@ -261,62 +262,45 @@ export function BudgetTable(props: BudgetTableProps) {
monthBounds={monthBounds}
type={type}
>
<SchedulesProvider query={schedulesQuery}>
{!budgetTableV2Enabled && (
<>
<BudgetTotals
toggleHiddenCategories={onToggleHiddenCategories}
expandAllCategories={onExpandAllCategories}
collapseAllCategories={onCollapseAllCategories}
/>
<View
style={{
overflowY: 'scroll',
overflowAnchor: 'none',
flex: 1,
paddingLeft: 5,
paddingRight: 5,
}}
>
<View
style={{
flexShrink: 0,
}}
onKeyDown={onKeyDown}
>
<BudgetCategories
categoryGroups={categoryGroups}
editingCell={editing}
onEditMonth={onEditMonth}
onEditName={onEditName}
onSaveCategory={onSaveCategory}
onSaveGroup={onSaveGroup}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
onReorderCategory={_onReorderCategory}
onReorderGroup={_onReorderGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onApplyBudgetTemplatesInGroup={
onApplyBudgetTemplatesInGroup
}
/>
</View>
</View>
</>
)}
{budgetTableV2Enabled && (
<View style={{ overflowY: 'auto' }}>
<BudgetCategoriesV2
<BudgetTotals
toggleHiddenCategories={toggleHiddenCategories}
expandAllCategories={expandAllCategories}
collapseAllCategories={collapseAllCategories}
/>
<View
style={{
overflowY: 'scroll',
overflowAnchor: 'none',
flex: 1,
paddingLeft: 5,
paddingRight: 5,
}}
>
<View
style={{
flexShrink: 0,
}}
onKeyDown={onKeyDown}
>
<SchedulesProvider query={schedulesQuery}>
<BudgetCategories
categoryGroups={categoryGroups}
editingCell={editing}
onEditMonth={onEditMonth}
onEditName={onEditName}
onSaveCategory={onSaveCategory}
onSaveGroup={onSaveGroup}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
onReorderCategory={_onReorderCategory}
onReorderGroup={_onReorderGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
onToggleHiddenCategories={onToggleHiddenCategories}
onExpandAllCategories={onExpandAllCategories}
onCollapseAllCategories={onCollapseAllCategories}
/>
</View>
)}
</SchedulesProvider>
</SchedulesProvider>
</View>
</View>
</MonthsProvider>
</View>
);

View File

@@ -1,229 +0,0 @@
import React, {
type ComponentPropsWithoutRef,
useRef,
useState,
useCallback,
} from 'react';
import { usePress, useFocusable } from 'react-aria';
import { Cell as ReactAriaCell } from 'react-aria-components';
import { SvgArrowThinRight } from '@actual-app/components/icons/v1';
import { Popover } from '@actual-app/components/popover';
import { type CSSProperties } from '@actual-app/components/styles';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import * as monthUtils from 'loot-core/shared/months';
import { type CategoryEntity } from 'loot-core/types/models';
import { balanceColumnPaddingStyle } from './BudgetCategoriesV2';
import { BalanceMovementMenu as EnvelopeBalanceMovementMenu } from './envelope/BalanceMovementMenu';
import { BalanceMenu as TrackingBalanceMenu } from './tracking/BalanceMenu';
import { makeBalanceAmountStyle } from './util';
import {
CellValue,
CellValueText,
} from '@desktop-client/components/spreadsheet/CellValue';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { type SheetNames } from '@desktop-client/spreadsheet';
import {
envelopeBudget,
trackingBudget,
} from '@desktop-client/spreadsheet/bindings';
type CategoryBalanceCellProps = ComponentPropsWithoutRef<
typeof ReactAriaCell
> & {
month: string;
category: CategoryEntity;
onBudgetAction: (month: string, action: string, args: unknown) => void;
};
export function CategoryBalanceCell({
month,
category,
onBudgetAction,
style,
...props
}: CategoryBalanceCellProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const triggerRef = useRef<HTMLSpanElement | null>(null);
const bindingBudgetType: SheetNames =
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
const categoryCarryoverBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.catCarryover(category.id)
: trackingBudget.catCarryover(category.id);
const categoryBalanceBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.catBalance(category.id)
: trackingBudget.catBalance(category.id);
const categoryBudgetedBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.catBudgeted(category.id)
: trackingBudget.catBudgeted(category.id);
const categoryGoalBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.catGoal(category.id)
: trackingBudget.catGoal(category.id);
const categoryLongGoalBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.catLongGoal(category.id)
: trackingBudget.catLongGoal(category.id);
const budgetedValue = useSheetValue<
typeof bindingBudgetType,
typeof categoryBudgetedBinding
>(categoryBudgetedBinding);
const goalValue = useSheetValue<
typeof bindingBudgetType,
typeof categoryGoalBinding
>(categoryGoalBinding);
const longGoalValue = useSheetValue<
typeof bindingBudgetType,
typeof categoryLongGoalBinding
>(categoryLongGoalBinding);
const [isBalanceMenuOpen, setIsBalanceMenuOpen] = useState(false);
const { pressProps } = usePress({
onPress: () => setIsBalanceMenuOpen(true),
});
const { focusableProps } = useFocusable(
{
onKeyUp: e => {
if (e.key === 'Enter') {
setIsBalanceMenuOpen(true);
}
},
},
triggerRef,
);
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const getBalanceAmountStyle = useCallback(
(balanceValue: number) =>
makeBalanceAmountStyle(
balanceValue,
isGoalTemplatesEnabled ? goalValue : null,
longGoalValue === 1 ? balanceValue : budgetedValue,
),
[budgetedValue, goalValue, isGoalTemplatesEnabled, longGoalValue],
);
// TODO: Refactor balance cell tooltips
return (
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<CellValue<typeof bindingBudgetType, typeof categoryBalanceBinding>
type="financial"
binding={categoryBalanceBinding}
>
{balanceProps => (
<View
style={{
position: 'relative',
display: 'inline-block',
...balanceColumnPaddingStyle,
}}
>
<CellValueText
innerRef={triggerRef}
{...pressProps}
{...focusableProps}
{...balanceProps}
className={css({
...getBalanceAmountStyle(balanceProps.value),
'&:hover': {
cursor: 'pointer',
textDecoration: 'underline',
},
})}
/>
<CellValue<
typeof bindingBudgetType,
typeof categoryCarryoverBinding
>
binding={categoryCarryoverBinding}
>
{carryOverProps =>
carryOverProps.value && (
<CarryoverIndicator
style={getBalanceAmountStyle(balanceProps.value)}
/>
)
}
</CellValue>
</View>
)}
</CellValue>
<Popover
triggerRef={triggerRef}
placement="bottom end"
isOpen={isBalanceMenuOpen}
onOpenChange={() => {
setIsBalanceMenuOpen(false);
}}
isNonModal
>
{budgetType === 'rollover' ? (
<EnvelopeBalanceMovementMenu
categoryId={category.id}
month={month}
onBudgetAction={onBudgetAction}
onSelect={() => setIsBalanceMenuOpen(false)}
/>
) : (
<TrackingBalanceMenu
categoryId={category.id}
onCarryover={carryover => {
onBudgetAction(month, 'carryover', {
category: category.id,
flag: carryover,
});
setIsBalanceMenuOpen(false);
}}
/>
)}
</Popover>
</SheetNameProvider>
</ReactAriaCell>
);
}
type CarryoverIndicatorProps = {
style?: CSSProperties;
};
function CarryoverIndicator({ style }: CarryoverIndicatorProps) {
return (
<View
style={{
position: 'absolute',
right: 0,
transform: 'translateY(-50%)',
top: '50%',
...style,
}}
>
<SvgArrowThinRight
width={style?.width || 7}
height={style?.height || 7}
style={style}
/>
</View>
);
}

View File

@@ -1,240 +0,0 @@
import React, { type ComponentPropsWithoutRef, useState } from 'react';
import { useFocusVisible } from 'react-aria';
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
import { Input } from '@actual-app/components/input';
import { Popover } from '@actual-app/components/popover';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
import * as monthUtils from 'loot-core/shared/months';
import {
currencyToAmount,
currencyToInteger,
type IntegerAmount,
} from 'loot-core/shared/util';
import type { CategoryEntity } from 'loot-core/types/models';
import {
hoverVisibleStyle,
getCellBackgroundStyle,
} from './BudgetCategoriesV2';
import { BudgetMenu as EnvelopeBudgetMenu } from './envelope/BudgetMenu';
import { BudgetMenu as TrackingBudgetMenu } from './tracking/BudgetMenu';
import { makeAmountGrey } from './util';
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { type SheetNames } from '@desktop-client/spreadsheet';
import {
envelopeBudget,
trackingBudget,
} from '@desktop-client/spreadsheet/bindings';
type CategoryBudgetedCellProps = ComponentPropsWithoutRef<
typeof ReactAriaCell
> & {
month: string;
category: CategoryEntity;
onBudgetAction: (month: string, action: string, args: unknown) => void;
};
export function CategoryBudgetedCell({
month,
category,
onBudgetAction,
...props
}: CategoryBudgetedCellProps) {
const { t } = useTranslation();
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [shouldHideBudgetMenuButton, setShouldHideBudgetMenuButton] =
useState(false);
const bindingBudgetType: SheetNames =
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
const budgetedBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.catBudgeted(category.id)
: trackingBudget.catBudgeted(category.id);
const BudgetMenuComponent =
bindingBudgetType === 'envelope-budget'
? EnvelopeBudgetMenu
: TrackingBudgetMenu;
const { showUndoNotification } = useUndo();
const onUpdateBudget = (amount: IntegerAmount) => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
};
const { isFocusVisible } = useFocusVisible();
return (
<ReactAriaCell {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<View
className={css({
flexDirection: 'row',
alignItems: 'center',
...hoverVisibleStyle,
})}
>
<DialogTrigger>
<Button
variant="bare"
aria-label={t('Budget menu')}
className={cx(
{ 'hover-visible': !isMenuOpen && !isFocusVisible },
css({
display:
shouldHideBudgetMenuButton && !isFocusVisible
? 'none'
: undefined,
}),
)}
onPress={() => setIsMenuOpen(true)}
>
<SvgCheveronDown width={12} height={12} />
</Button>
<Popover
placement="bottom start"
isOpen={isMenuOpen}
onOpenChange={() => setIsMenuOpen(false)}
isNonModal
>
<BudgetMenuComponent
onCopyLastMonthAverage={() => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: t(`Budget set to last month's budget.`),
});
setIsMenuOpen(false);
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: t(
'Budget set to {{numberOfMonths}}-month average.',
{ numberOfMonths },
),
});
setIsMenuOpen(false);
}}
onApplyBudgetTemplate={() => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: t(`Budget template applied.`),
});
setIsMenuOpen(false);
}}
/>
</Popover>
</DialogTrigger>
<View style={{ flex: 1 }}>
<CellValue<typeof bindingBudgetType, typeof budgetedBinding>
type="financial"
binding={budgetedBinding}
>
{({ value: budgetedAmount }) => (
<BudgetedInput
value={budgetedAmount}
onFocus={() => setShouldHideBudgetMenuButton(true)}
onBlur={() => setShouldHideBudgetMenuButton(false)}
style={getCellBackgroundStyle('budgeted', month)}
onUpdateAmount={onUpdateBudget}
/>
)}
</CellValue>
</View>
</View>
</SheetNameProvider>
</ReactAriaCell>
);
}
type BudgetedInputProps = Omit<
ComponentPropsWithoutRef<typeof Input>,
'value'
> & {
value: IntegerAmount;
onUpdateAmount: (newValue: IntegerAmount) => void;
};
function BudgetedInput({
value,
onFocus,
onChangeValue,
onUpdate,
onUpdateAmount,
...props
}: BudgetedInputProps) {
const format = useFormat();
const [currentFormattedAmount, setCurrentFormattedAmount] = useState<
string | null
>(null);
return (
<Input
value={currentFormattedAmount ?? format(value, 'financial')}
onFocus={e => {
onFocus?.(e);
if (!e.defaultPrevented) {
e.target.select();
}
}}
onEscape={() => setCurrentFormattedAmount(format(value, 'financial'))}
className={css({
...makeAmountGrey(
currentFormattedAmount
? currencyToAmount(currentFormattedAmount)
: value,
),
textAlign: 'right',
border: '1px solid transparent',
'&:hover:not(:focus)': {
border: `1px solid ${theme.formInputBorder}`,
},
})}
onChangeValue={(newValue, e) => {
onChangeValue?.(newValue, e);
setCurrentFormattedAmount(newValue);
}}
onUpdate={(newValue, e) => {
onUpdate?.(newValue, e);
const integerAmount = currencyToInteger(newValue);
if (integerAmount) {
onUpdateAmount?.(integerAmount);
setCurrentFormattedAmount(format(integerAmount, 'financial'));
}
}}
{...props}
/>
);
}

View File

@@ -1,62 +0,0 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { Cell as ReactAriaCell } from 'react-aria-components';
import * as monthUtils from 'loot-core/shared/months';
import type { CategoryGroupEntity } from 'loot-core/types/models';
import { balanceColumnPaddingStyle } from './BudgetCategoriesV2';
import {
CellValue,
CellValueText,
} from '@desktop-client/components/spreadsheet/CellValue';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { type SheetNames } from '@desktop-client/spreadsheet';
import {
envelopeBudget,
trackingBudget,
} from '@desktop-client/spreadsheet/bindings';
type CategoryGroupBalanceCellProps = ComponentPropsWithoutRef<
typeof ReactAriaCell
> & {
month: string;
categoryGroup: CategoryGroupEntity;
};
export function CategoryGroupBalanceCell({
month,
categoryGroup,
style,
...props
}: CategoryGroupBalanceCellProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const bindingBudgetType: SheetNames =
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
const groupBalanceBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.groupBalance(categoryGroup.id)
: trackingBudget.groupBalance(categoryGroup.id);
return (
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<CellValue<typeof bindingBudgetType, typeof groupBalanceBinding>
type="financial"
binding={groupBalanceBinding}
>
{props => (
<CellValueText
{...props}
style={{
...balanceColumnPaddingStyle,
}}
/>
)}
</CellValue>
</SheetNameProvider>
</ReactAriaCell>
);
}

View File

@@ -1,60 +0,0 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { Cell as ReactAriaCell } from 'react-aria-components';
import * as monthUtils from 'loot-core/shared/months';
import type { CategoryGroupEntity } from 'loot-core/types/models';
import {
CellValue,
CellValueText,
} from '@desktop-client/components/spreadsheet/CellValue';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { type SheetNames } from '@desktop-client/spreadsheet';
import {
envelopeBudget,
trackingBudget,
} from '@desktop-client/spreadsheet/bindings';
type CategoryGroupBudgetedCellProps = ComponentPropsWithoutRef<
typeof ReactAriaCell
> & {
month: string;
categoryGroup: CategoryGroupEntity;
};
export function CategoryGroupBudgetedCell({
month,
categoryGroup,
style,
...props
}: CategoryGroupBudgetedCellProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const bindingBudgetType: SheetNames =
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
const groupBudgetedBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.groupBudgeted(categoryGroup.id)
: trackingBudget.groupBudgeted(categoryGroup.id);
return (
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<CellValue<typeof bindingBudgetType, typeof groupBudgetedBinding>
type="financial"
binding={groupBudgetedBinding}
>
{props => (
<CellValueText
{...props}
style={{
paddingRight: 5,
}}
/>
)}
</CellValue>
</SheetNameProvider>
</ReactAriaCell>
);
}

View File

@@ -1,213 +0,0 @@
import React, { type ComponentPropsWithoutRef, useState } from 'react';
import { useFocusVisible } from 'react-aria';
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgAdd, SvgExpandArrow } from '@actual-app/components/icons/v0';
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
import { Input } from '@actual-app/components/input';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
import * as monthUtils from 'loot-core/shared/months';
import type { CategoryGroupEntity } from 'loot-core/types/models';
import { hoverVisibleStyle } from './BudgetCategoriesV2';
import { NotesButton } from '@desktop-client/components/NotesButton';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
type CategoryGroupNameCellProps = ComponentPropsWithoutRef<
typeof ReactAriaCell
> & {
month: string;
categoryGroup: CategoryGroupEntity;
isCollapsed: boolean;
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
onDelete: (categoryGroup: CategoryGroupEntity) => void;
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
};
export function CategoryGroupNameCell({
month,
categoryGroup,
isCollapsed,
onToggleCollapse,
onAddCategory,
onRename,
onDelete,
onToggleVisibilty,
onApplyBudgetTemplatesInGroup,
...props
}: CategoryGroupNameCellProps) {
const { t } = useTranslation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const { isFocusVisible } = useFocusVisible();
return (
<ReactAriaCell {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<View
className={css({
paddingLeft: 5,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
...hoverVisibleStyle,
})}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
{/* Hidden drag button */}
<Button
slot="drag"
style={{
opacity: 0,
width: 1,
height: 1,
position: 'absolute',
overflow: 'hidden',
}}
/>
<Button
variant="bare"
onPress={() => onToggleCollapse(categoryGroup)}
isDisabled={isRenaming}
>
<SvgExpandArrow
width={8}
height={8}
style={{
flexShrink: 0,
transition: 'transform .1s',
transform: isCollapsed ? 'rotate(-90deg)' : '',
}}
/>
</Button>
{isRenaming ? (
<View style={{ flex: 1 }}>
<Input
autoFocus
defaultValue={categoryGroup.name}
onBlur={() => setIsRenaming(false)}
onEscape={() => setIsRenaming(false)}
onUpdate={newName => {
if (newName !== categoryGroup.name) {
onRename(categoryGroup, newName);
}
}}
/>
</View>
) : (
<>
<Text style={{ fontWeight: 600 }}>{categoryGroup.name}</Text>
<DialogTrigger>
<Button
variant="bare"
className={cx({
'hover-visible': !isMenuOpen && !isFocusVisible,
})}
onPress={() => {
// resetPosition();
setIsMenuOpen(true);
}}
>
<SvgCheveronDown width={12} height={12} />
</Button>
<Popover
placement="bottom start"
isOpen={isMenuOpen}
onOpenChange={() => setIsMenuOpen(false)}
isNonModal
>
<Menu
onMenuSelect={type => {
if (type === 'rename') {
// onEdit(categoryGroup.id);
setIsRenaming(true);
} else if (type === 'delete') {
onDelete(categoryGroup);
} else if (type === 'toggle-visibility') {
// onSave({ ...categoryGroup, hidden: !categoryGroup.hidden });
onToggleVisibilty(categoryGroup);
} else if (
type === 'apply-multiple-category-template'
) {
onApplyBudgetTemplatesInGroup(categoryGroup);
}
setIsMenuOpen(false);
}}
items={[
{ name: 'rename', text: t('Rename') },
...(!categoryGroup.is_income
? [
{
name: 'toggle-visibility',
text: categoryGroup.hidden ? 'Show' : 'Hide',
},
{ name: 'delete', text: t('Delete') },
]
: []),
...(isGoalTemplatesEnabled
? [
{
name: 'apply-multiple-category-template',
text: t('Overwrite with templates'),
},
]
: []),
]}
/>
</Popover>
</DialogTrigger>
</>
)}
</View>
{!isRenaming && (
<View
style={{
flexDirection: 'row',
flexShrink: 0,
alignItems: 'center',
}}
>
<Tooltip content={t('Add category')} disablePointerEvents>
<Button
variant="bare"
aria-label={t('Add category')}
className={cx(
css({
color: theme.pageTextLight,
}),
'hover-visible',
)}
onPress={() => {
onAddCategory(categoryGroup);
}}
>
<SvgAdd style={{ width: 10, height: 10, flexShrink: 0 }} />
</Button>
</Tooltip>
<NotesButton
id={categoryGroup.id}
defaultColor={theme.pageTextLight}
/>
</View>
)}
</View>
</SheetNameProvider>
</ReactAriaCell>
);
}

View File

@@ -1,60 +0,0 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { Cell as ReactAriaCell } from 'react-aria-components';
import * as monthUtils from 'loot-core/shared/months';
import type { CategoryGroupEntity } from 'loot-core/types/models';
import {
CellValue,
CellValueText,
} from '@desktop-client/components/spreadsheet/CellValue';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { type SheetNames } from '@desktop-client/spreadsheet';
import {
envelopeBudget,
trackingBudget,
} from '@desktop-client/spreadsheet/bindings';
type CategoryGroupSpentCellProps = ComponentPropsWithoutRef<
typeof ReactAriaCell
> & {
month: string;
categoryGroup: CategoryGroupEntity;
};
export function CategoryGroupSpentCell({
month,
categoryGroup,
style,
...props
}: CategoryGroupSpentCellProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const bindingBudgetType: SheetNames =
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
const groupSpentBinding =
bindingBudgetType === 'envelope-budget'
? envelopeBudget.groupSumAmount(categoryGroup.id)
: trackingBudget.groupSumAmount(categoryGroup.id);
return (
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<CellValue<typeof bindingBudgetType, typeof groupSpentBinding>
type="financial"
binding={groupSpentBinding}
>
{props => (
<CellValueText
{...props}
style={{
paddingRight: 5,
}}
/>
)}
</CellValue>
</SheetNameProvider>
</ReactAriaCell>
);
}

View File

@@ -1,153 +0,0 @@
import React, { type ComponentPropsWithoutRef, useState } from 'react';
import { useFocusVisible } from 'react-aria';
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
import { Input } from '@actual-app/components/input';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
import * as monthUtils from 'loot-core/shared/months';
import type {
CategoryEntity,
CategoryGroupEntity,
} from 'loot-core/types/models';
import { hoverVisibleStyle } from './BudgetCategoriesV2';
import { NotesButton } from '@desktop-client/components/NotesButton';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
type CategoryNameCellProps = ComponentPropsWithoutRef<typeof ReactAriaCell> & {
month: string;
category: CategoryEntity;
categoryGroup: CategoryGroupEntity;
onRename: (category: CategoryEntity, newName: string) => void;
onDelete: (category: CategoryEntity) => void;
onToggleVisibility: (category: CategoryEntity) => void;
};
export function CategoryNameCell({
month,
category,
categoryGroup,
onRename,
onDelete,
onToggleVisibility,
...props
}: CategoryNameCellProps) {
const { t } = useTranslation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const { isFocusVisible } = useFocusVisible();
return (
<ReactAriaCell {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<View
className={css({
paddingLeft: 18,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
...hoverVisibleStyle,
})}
>
{/* Hidden drag button */}
<Button
slot="drag"
style={{
opacity: 0,
width: 1,
height: 1,
position: 'absolute',
overflow: 'hidden',
}}
/>
{isRenaming ? (
<View style={{ flex: 1 }}>
<Input
defaultValue={category.name}
placeholder={t('Enter category name')}
onBlur={() => setIsRenaming(false)}
onUpdate={newName => {
if (newName !== category.name) {
onRename(category, newName);
}
}}
/>
</View>
) : (
<>
<View
style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
>
<Text>{category.name}</Text>
<DialogTrigger>
<Button
variant="bare"
className={cx({
'hover-visible': !isMenuOpen && !isFocusVisible,
})}
onPress={() => {
// resetPosition();
setIsMenuOpen(true);
}}
>
<SvgCheveronDown width={12} height={12} />
</Button>
<Popover
placement="bottom start"
isOpen={isMenuOpen}
onOpenChange={() => setIsMenuOpen(false)}
isNonModal
>
<Menu
onMenuSelect={type => {
if (type === 'rename') {
// onEditName(category.id);
setIsRenaming(true);
} else if (type === 'delete') {
onDelete(category);
} else if (type === 'toggle-visibility') {
// onSave({ ...category, hidden: !category.hidden });
onToggleVisibility(category);
}
setIsMenuOpen(false);
}}
items={[
{ name: 'rename', text: t('Rename') },
...(!categoryGroup?.hidden
? [
{
name: 'toggle-visibility',
text: category.hidden ? t('Show') : t('Hide'),
},
]
: []),
{ name: 'delete', text: t('Delete') },
]}
/>
</Popover>
</DialogTrigger>
</View>
<View>
<NotesButton
id={category.id}
defaultColor={theme.pageTextLight}
/>
</View>
</>
)}
</View>
</SheetNameProvider>
</ReactAriaCell>
);
}

View File

@@ -1,86 +0,0 @@
import React, { type ComponentPropsWithoutRef, useRef } from 'react';
import { usePress, useFocusable } from 'react-aria';
import { Cell as ReactAriaCell } from 'react-aria-components';
import { css } from '@emotion/css';
import * as monthUtils from 'loot-core/shared/months';
import type { CategoryEntity } from 'loot-core/types/models';
import { makeAmountGrey } from './util';
import {
CellValue,
CellValueText,
} from '@desktop-client/components/spreadsheet/CellValue';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import {
envelopeBudget,
trackingBudget,
} from '@desktop-client/spreadsheet/bindings';
type CategorySpentCellProps = ComponentPropsWithoutRef<typeof ReactAriaCell> & {
month: string;
category: CategoryEntity;
onShowActivity: (category: CategoryEntity, month: string) => void;
};
export function CategorySpentCell({
month,
category,
onShowActivity,
style,
...props
}: CategorySpentCellProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const categorySpentBinding =
budgetType === 'rollover'
? envelopeBudget.catSumAmount(category.id)
: trackingBudget.catSumAmount(category.id);
const { pressProps } = usePress({
onPress: () => onShowActivity(category, month),
});
const textRef = useRef<HTMLSpanElement | null>(null);
const { focusableProps } = useFocusable(
{
onKeyUp: e => {
if (e.key === 'Enter') {
onShowActivity(category, month);
}
},
},
textRef,
);
return (
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
<CellValue<'envelope-budget', 'sum-amount'>
type="financial"
binding={categorySpentBinding}
>
{props => (
<CellValueText
innerRef={textRef}
{...pressProps}
{...focusableProps}
{...props}
className={css({
...makeAmountGrey(props.value),
'&:hover': {
cursor: 'pointer',
textDecoration: 'underline',
},
paddingRight: 5,
})}
/>
)}
</CellValue>
</SheetNameProvider>
</ReactAriaCell>
);
}

View File

@@ -11,7 +11,6 @@ import { useBudgetMonthCount } from './BudgetMonthCountContext';
import { BudgetPageHeader } from './BudgetPageHeader';
import { BudgetTable } from './BudgetTable';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
function getNumPossibleMonths(width: number, categoryWidth: number) {
@@ -79,12 +78,8 @@ const DynamicBudgetTable = ({
onMonthSelect(getValidMonth(month), numMonths);
}
// Table V2 uses alt+left/right for month navigation
// so that users can use left/right to navigate cells
const budgetTableV2Enabled = useFeatureFlag('budgetTableV2');
useHotkeys(
budgetTableV2Enabled ? 'alt+left' : 'left',
'left',
() => {
_onMonthSelect(monthUtils.prevMonth(startMonth));
},
@@ -95,7 +90,7 @@ const DynamicBudgetTable = ({
[_onMonthSelect, startMonth],
);
useHotkeys(
budgetTableV2Enabled ? 'alt+right' : 'right',
'right',
() => {
_onMonthSelect(monthUtils.nextMonth(startMonth));
},

View File

@@ -1,99 +0,0 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { Row as ReactAriaRow } from 'react-aria-components';
import type { CategoryGroupEntity } from 'loot-core/types/models';
import {
type ColumnDefinition,
getCategoryGroupRowStyle,
getHeaderBackgroundStyle,
} from './BudgetCategoriesV2';
import { CategoryGroupBalanceCell } from './CategoryGroupBalanceCell';
import { CategoryGroupBudgetedCell } from './CategoryGroupBudgetedCell';
import { CategoryGroupNameCell } from './CategoryGroupNameCell';
import { CategoryGroupSpentCell } from './CategoryGroupSpentCell';
type ExpenseCategoryGroupRowProps = ComponentPropsWithoutRef<
typeof ReactAriaRow<ColumnDefinition>
> & {
item: {
type: 'expense-group';
id: `expense-group-${string}`;
value: CategoryGroupEntity;
};
isCollapsed: boolean;
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
onDelete: (categoryGroup: CategoryGroupEntity) => void;
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
};
export function ExpenseCategoryGroupRow({
item,
isCollapsed,
onToggleCollapse,
onAddCategory,
onRename,
onDelete,
onToggleVisibilty,
onApplyBudgetTemplatesInGroup,
style,
...props
}: ExpenseCategoryGroupRowProps) {
return (
<ReactAriaRow
style={{
...getCategoryGroupRowStyle(item.value),
...style,
}}
{...props}
>
{column => {
switch (column.type) {
case 'category':
return (
<CategoryGroupNameCell
month={column.month}
categoryGroup={item.value}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
onAddCategory={onAddCategory}
onRename={onRename}
onDelete={onDelete}
onToggleVisibilty={onToggleVisibilty}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
/>
);
case 'budgeted':
return (
<CategoryGroupBudgetedCell
month={column.month}
categoryGroup={item.value}
style={getHeaderBackgroundStyle(column.type, column.month)}
/>
);
case 'spent':
return (
<CategoryGroupSpentCell
month={column.month}
categoryGroup={item.value}
style={getHeaderBackgroundStyle(column.type, column.month)}
/>
);
case 'balance':
return (
<CategoryGroupBalanceCell
month={column.month}
categoryGroup={item.value}
style={getHeaderBackgroundStyle(column.type, column.month)}
/>
);
default:
throw new Error(`Unrecognized column type: ${column.type}`);
}
}}
</ReactAriaRow>
);
}

View File

@@ -1,98 +0,0 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { Row as ReactAriaRow } from 'react-aria-components';
import type {
CategoryEntity,
CategoryGroupEntity,
} from 'loot-core/types/models';
import {
type ColumnDefinition,
getCategoryRowStyle,
getCellBackgroundStyle,
} from './BudgetCategoriesV2';
import { CategoryBalanceCell } from './CategoryBalanceCell';
import { CategoryBudgetedCell } from './CategoryBudgetedCell';
import { CategoryNameCell } from './CategoryNameCell';
import { CategorySpentCell } from './CategorySpentCell';
type ExpenseCategoryRowProps = ComponentPropsWithoutRef<
typeof ReactAriaRow<ColumnDefinition>
> & {
item: {
type: 'expense-category';
id: `expense-category-${string}`;
value: CategoryEntity;
group: CategoryGroupEntity;
};
onBudgetAction: (month: string, action: string, args: unknown) => void;
onShowActivity: (category: CategoryEntity, month: string) => void;
onRename: (category: CategoryEntity, newName: string) => void;
onDelete: (category: CategoryEntity) => void;
onToggleVisibility: (category: CategoryEntity) => void;
};
export function ExpenseCategoryRow({
item,
onBudgetAction,
onShowActivity,
onRename,
onDelete,
onToggleVisibility,
style,
...props
}: ExpenseCategoryRowProps) {
return (
<ReactAriaRow
style={{
...getCategoryRowStyle(item.value, item.group),
...style,
}}
{...props}
>
{column => {
switch (column.type) {
case 'category':
return (
<CategoryNameCell
month={column.month}
category={item.value}
categoryGroup={item.group}
onRename={onRename}
onDelete={onDelete}
onToggleVisibility={onToggleVisibility}
/>
);
case 'budgeted':
return (
<CategoryBudgetedCell
month={column.month}
category={item.value}
onBudgetAction={onBudgetAction}
style={getCellBackgroundStyle(column.type, column.month)}
/>
);
case 'spent':
return (
<CategorySpentCell
month={column.month}
category={item.value}
onShowActivity={onShowActivity}
style={getCellBackgroundStyle(column.type, column.month)}
/>
);
case 'balance':
return (
<CategoryBalanceCell
month={column.month}
category={item.value}
onBudgetAction={onBudgetAction}
style={getCellBackgroundStyle(column.type, column.month)}
/>
);
default:
throw new Error(`Unrecognized column type: ${column.type}`);
}
}}
</ReactAriaRow>
);
}

View File

@@ -1,136 +0,0 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import {
Row as ReactAriaRow,
Cell as ReactAriaCell,
} from 'react-aria-components';
import type { CategoryGroupEntity } from 'loot-core/types/models';
import {
type ColumnDefinition,
getCategoryGroupRowStyle,
} from './BudgetCategoriesV2';
import { CategoryGroupBalanceCell } from './CategoryGroupBalanceCell';
import { CategoryGroupBudgetedCell } from './CategoryGroupBudgetedCell';
import { CategoryGroupNameCell } from './CategoryGroupNameCell';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
type IncomeCategoryGroupRowProps = ComponentPropsWithoutRef<
typeof ReactAriaRow<ColumnDefinition>
> & {
item: {
type: 'income-group';
id: `income-group-${string}`;
value: CategoryGroupEntity;
};
isCollapsed: boolean;
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
onDelete: (categoryGroup: CategoryGroupEntity) => void;
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
};
export function IncomeCategoryGroupRow({
item,
isCollapsed,
onToggleCollapse,
onAddCategory,
onRename,
onDelete,
onToggleVisibilty,
onApplyBudgetTemplatesInGroup,
style,
...props
}: IncomeCategoryGroupRowProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
return budgetType === 'rollover' ? (
<ReactAriaRow
style={{
...getCategoryGroupRowStyle(item.value),
...style,
}}
{...props}
>
{column => {
switch (column.type) {
case 'category':
return (
<CategoryGroupNameCell
month={column.month}
categoryGroup={item.value}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
onAddCategory={onAddCategory}
onRename={onRename}
onDelete={onDelete}
onToggleVisibilty={onToggleVisibilty}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
/>
);
case 'budgeted':
return <ReactAriaCell />;
case 'spent':
return <ReactAriaCell />;
case 'balance':
return (
<CategoryGroupBalanceCell
month={column.month}
categoryGroup={item.value}
/>
);
default:
throw new Error(`Unrecognized column type: ${column.type}`);
}
}}
</ReactAriaRow>
) : (
<ReactAriaRow
style={{
...getCategoryGroupRowStyle(item.value),
...style,
}}
{...props}
>
{column => {
switch (column.type) {
case 'category':
return (
<CategoryGroupNameCell
month={column.month}
categoryGroup={item.value}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
onAddCategory={onAddCategory}
onRename={onRename}
onDelete={onDelete}
onToggleVisibilty={onToggleVisibilty}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
/>
);
case 'budgeted':
return (
<CategoryGroupBudgetedCell
month={column.month}
categoryGroup={item.value}
/>
);
case 'spent':
return <ReactAriaCell />;
case 'balance':
return (
<CategoryGroupBalanceCell
month={column.month}
categoryGroup={item.value}
/>
);
default:
throw new Error(`Unrecognized column type: ${column.type}`);
}
}}
</ReactAriaRow>
);
}

View File

@@ -1,129 +0,0 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import {
Row as ReactAriaRow,
Cell as ReactAriaCell,
} from 'react-aria-components';
import type {
CategoryEntity,
CategoryGroupEntity,
} from 'loot-core/types/models';
import {
type ColumnDefinition,
getCategoryRowStyle,
} from './BudgetCategoriesV2';
import { CategoryBalanceCell } from './CategoryBalanceCell';
import { CategoryBudgetedCell } from './CategoryBudgetedCell';
import { CategoryNameCell } from './CategoryNameCell';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
type IncomeCategoryRowProps = ComponentPropsWithoutRef<
typeof ReactAriaRow<ColumnDefinition>
> & {
item: {
type: 'income-category';
id: `income-category-${string}`;
value: CategoryEntity;
group: CategoryGroupEntity;
};
onBudgetAction: (month: string, action: string, args: unknown) => void;
onRename: (category: CategoryEntity, newName: string) => void;
onDelete: (category: CategoryEntity) => void;
onToggleVisibility: (category: CategoryEntity) => void;
};
export function IncomeCategoryRow({
item,
onBudgetAction,
onRename,
onDelete,
onToggleVisibility,
style,
...props
}: IncomeCategoryRowProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
return budgetType === 'rollover' ? (
<ReactAriaRow
style={{
...getCategoryRowStyle(item.value),
...style,
}}
{...props}
>
{column => {
switch (column.type) {
case 'category':
return (
<CategoryNameCell
month={column.month}
category={item.value}
categoryGroup={item.group}
onRename={onRename}
onDelete={onDelete}
onToggleVisibility={onToggleVisibility}
/>
);
case 'budgeted':
return <ReactAriaCell />;
case 'spent':
return <ReactAriaCell />;
case 'balance':
return (
<CategoryBalanceCell
month={column.month}
category={item.value}
onBudgetAction={onBudgetAction}
/>
);
default:
throw new Error(`Unrecognized column type: ${column.type}`);
}
}}
</ReactAriaRow>
) : (
<ReactAriaRow
style={{
...getCategoryRowStyle(item.value),
...style,
}}
{...props}
>
{column => {
switch (column.type) {
case 'category':
return (
<CategoryNameCell
month={column.month}
category={item.value}
categoryGroup={item.group}
onRename={onRename}
onDelete={onDelete}
onToggleVisibility={onToggleVisibility}
/>
);
case 'budgeted':
return (
<CategoryBudgetedCell
month={column.month}
category={item.value}
onBudgetAction={onBudgetAction}
/>
);
case 'spent':
return <ReactAriaCell />;
case 'balance':
return (
<CategoryBalanceCell
month={column.month}
category={item.value}
onBudgetAction={onBudgetAction}
/>
);
default:
throw new Error(`Unrecognized column type: ${column.type}`);
}
}}
</ReactAriaRow>
);
}

View File

@@ -12,14 +12,14 @@ type BalanceMovementMenuProps = {
categoryId: string;
month: string;
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
onSelect?: () => void;
onClose?: () => void;
};
export function BalanceMovementMenu({
categoryId,
month,
onBudgetAction,
onSelect = () => {},
onClose = () => {},
}: BalanceMovementMenuProps) {
const format = useFormat();
@@ -48,7 +48,7 @@ export function BalanceMovementMenu({
category: categoryId,
flag: carryover,
});
onSelect();
onClose();
}}
onTransfer={() => setMenu('transfer')}
onCover={() => setMenu('cover')}
@@ -60,6 +60,7 @@ export function BalanceMovementMenu({
categoryId={categoryId}
initialAmount={catBalance}
showToBeBudgeted
onClose={onClose}
onSubmit={(amount, toCategoryId) => {
onBudgetAction(month, 'transfer-category', {
amount,
@@ -67,7 +68,6 @@ export function BalanceMovementMenu({
to: toCategoryId,
currencyCode: format.currency.code,
});
onSelect();
}}
/>
)}
@@ -76,6 +76,7 @@ export function BalanceMovementMenu({
<CoverMenu
categoryId={categoryId}
initialAmount={catBalance}
onClose={onClose}
onSubmit={(amount, fromCategoryId) => {
onBudgetAction(month, 'cover-overspending', {
to: categoryId,
@@ -83,7 +84,6 @@ export function BalanceMovementMenu({
amount,
currencyCode: format.currency.code,
});
onSelect();
}}
/>
)}

View File

@@ -27,6 +27,7 @@ type CoverMenuProps = {
initialAmount?: IntegerAmount | null;
categoryId?: CategoryEntity['id'];
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
onClose: () => void;
};
export function CoverMenu({
@@ -34,6 +35,7 @@ export function CoverMenu({
initialAmount = 0,
categoryId,
onSubmit,
onClose,
}: CoverMenuProps) {
const { t } = useTranslation();
@@ -59,6 +61,7 @@ export function CoverMenu({
if (parsedAmount && fromCategoryId) {
onSubmit(amountToInteger(parsedAmount), fromCategoryId);
}
onClose();
}
return (

View File

@@ -508,7 +508,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
categoryId={category.id}
month={month}
onBudgetAction={onBudgetAction}
onSelect={() => setBalanceMenuOpen(false)}
onClose={() => setBalanceMenuOpen(false)}
/>
</Popover>
</Field>

View File

@@ -22,6 +22,7 @@ type TransferMenuProps = {
initialAmount?: IntegerAmount | null;
showToBeBudgeted?: boolean;
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
onClose: () => void;
};
export function TransferMenu({
@@ -29,6 +30,7 @@ export function TransferMenu({
initialAmount = 0,
showToBeBudgeted,
onSubmit,
onClose,
}: TransferMenuProps) {
const { grouped: originalCategoryGroups } = useCategories();
const filteredCategoryGroups = useMemo(() => {
@@ -52,6 +54,7 @@ export function TransferMenu({
if (amount != null && amount > 0 && toCategoryId) {
onSubmit(amount, toCategoryId);
}
onClose();
};
return (

View File

@@ -119,12 +119,12 @@ export function ToBudget({
{menuStep === 'transfer' && (
<TransferMenu
initialAmount={availableValue}
onClose={() => setMenuOpen(false)}
onSubmit={(amount, categoryId) => {
onBudgetAction(month, 'transfer-available', {
amount,
category: categoryId,
});
setMenuOpen(false);
}}
/>
)}
@@ -132,13 +132,13 @@ export function ToBudget({
<CoverMenu
showToBeBudgeted={false}
initialAmount={availableValue}
onClose={() => setMenuOpen(false)}
onSubmit={(amount, categoryId) => {
onBudgetAction(month, 'cover-overbudgeted', {
category: categoryId,
amount,
currencyCode: format.currency.code,
});
setMenuOpen(false);
}}
/>
)}

View File

@@ -23,7 +23,7 @@ import {
getCategories,
} from '@desktop-client/budget/budgetSlice';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations';
import { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
@@ -138,7 +138,7 @@ export function Budget() {
onShowActivity,
onReorderCategory,
onReorderGroup,
} = useCategoryMutations();
} = useCategoryActions();
if (!initialized || !categoryGroups) {
return null;

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

@@ -202,15 +202,6 @@ export function ExperimentalFeatures() {
>
<Trans>Crossover Report</Trans>
</FeatureToggle>
<FeatureToggle flag="forceReload">
<Trans>Force reload app button</Trans>
</FeatureToggle>
<FeatureToggle
flag="budgetTableV2"
feedbackLink="https://github.com/actualbudget/actual/pull/CHANGEME"
>
<Trans>Rewrite of desktop budget table</Trans>
</FeatureToggle>
{showServerPrefs && (
<ServerFeatureToggle
prefName="flags.plugins"

View File

@@ -3,7 +3,6 @@ import React, {
type ComponentPropsWithoutRef,
type ReactNode,
type CSSProperties,
Fragment,
} from 'react';
import { styles } from '@actual-app/components/styles';
@@ -44,15 +43,10 @@ export function CellValue<
const { fullSheetName } = useSheetName(binding);
const sheetValue = useSheetValue(binding);
// Re-render when these value changes.
const key = `${fullSheetName}|${sheetValue}`;
return typeof children === 'function' ? (
<Fragment key={key}>
{children({ type, name: fullSheetName, value: sheetValue })}
</Fragment>
<>{children({ type, name: fullSheetName, value: sheetValue })}</>
) : (
<CellValueText
key={key}
type={type}
name={fullSheetName}
value={sheetValue}

View File

@@ -1,228 +0,0 @@
import { useTranslation } from 'react-i18next';
import { send } from 'loot-core/platform/client/fetch';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { useCategories } from './useCategories';
import { useNavigate } from './useNavigate';
import {
createCategory,
createCategoryGroup,
deleteCategory,
deleteCategoryGroup,
moveCategory,
moveCategoryGroup,
updateCategory,
updateCategoryGroup,
} from '@desktop-client/budget/budgetSlice';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function useCategoryMutations() {
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const { grouped: categoryGroups } = useCategories();
const categoryNameAlreadyExistsNotification = (
name: CategoryEntity['name'],
) => {
dispatch(
addNotification({
notification: {
type: 'error',
message: t(
'Category "{{name}}" already exists in group (it may be hidden)',
{ name },
),
},
}),
);
};
const onSaveCategory = async (category: CategoryEntity) => {
const { grouped: categoryGroups = [] } = await send('get-categories');
const group = categoryGroups.find(g => g.id === category.group);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === category.name.toUpperCase())
.filter(c => (category.id === 'new' ? true : c.id !== category.id))
.length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(category.name);
return;
}
if (category.id === 'new') {
dispatch(
createCategory({
name: category.name,
groupId: category.group,
isIncome: !!category.is_income,
isHidden: !!category.hidden,
}),
);
} else {
dispatch(updateCategory({ category }));
}
};
const onDeleteCategory = async (id: CategoryEntity['id']) => {
const mustTransfer = await send('must-category-transfer', { id });
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
category: id,
onDelete: transferCategory => {
if (id !== transferCategory) {
dispatch(
deleteCategory({ id, transferId: transferCategory }),
);
}
},
},
},
}),
);
} else {
dispatch(deleteCategory({ id }));
}
};
const onSaveGroup = (group: CategoryGroupEntity) => {
if (group.id === 'new') {
dispatch(createCategoryGroup({ name: group.name }));
} else {
dispatch(updateCategoryGroup({ group }));
}
};
const onDeleteGroup = async (id: CategoryGroupEntity['id']) => {
const group = categoryGroups.find(g => g.id === id);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
let mustTransfer = false;
for (const category of groupCategories) {
if (await send('must-category-transfer', { id: category.id })) {
mustTransfer = true;
break;
}
}
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
group: id,
onDelete: transferCategory => {
dispatch(
deleteCategoryGroup({ id, transferId: transferCategory }),
);
},
},
},
}),
);
} else {
dispatch(deleteCategoryGroup({ id }));
}
};
const onShowActivity = (categoryId: CategoryEntity['id'], month: string) => {
const filterConditions = [
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
{
field: 'date',
op: 'is',
value: month,
options: { month: true },
type: 'date',
},
];
navigate('/accounts', {
state: {
goBack: true,
filterConditions,
categoryId,
},
});
};
const onReorderCategory = async (sortInfo: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
}) => {
const { grouped: categoryGroups = [], list: categories = [] } =
await send('get-categories');
const moveCandidate = categories.find(c => c.id === sortInfo.id);
const group = categoryGroups.find(g => g.id === sortInfo.groupId);
if (!moveCandidate || !group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === moveCandidate.name.toUpperCase())
.filter(c => c.id !== moveCandidate.id).length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(moveCandidate.name);
return;
}
dispatch(
moveCategory({
id: moveCandidate.id,
groupId: group.id,
targetId: sortInfo.targetId,
}),
);
};
const onReorderGroup = async (sortInfo: {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
}) => {
dispatch(
moveCategoryGroup({ id: sortInfo.id, targetId: sortInfo.targetId }),
);
};
return {
onSaveCategory,
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onShowActivity,
onReorderCategory,
onReorderGroup,
};
}

View File

@@ -9,9 +9,6 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
formulaMode: false,
currency: false,
crossoverReport: false,
plugins: false,
forceReload: false,
budgetTableV2: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {

View File

@@ -4,10 +4,7 @@ export type FeatureFlag =
| 'actionTemplating'
| 'formulaMode'
| 'currency'
| 'crossoverReport'
| 'plugins'
| 'forceReload'
| 'budgetTableV2';
| 'crossoverReport';
/**
* Cross-device preferences. These sync across devices when they are changed.

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: [joel-jeremy]
---
[Experimental] Rewrite of desktop budget table

View File

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