Compare commits

...

27 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
49ed0ab628 Drag and drop 2026-01-12 13:45:58 -08:00
Joel Jeremy Marquez
d799ede866 Fix lint errors 2026-01-12 13:43:05 -08:00
Joel Jeremy Marquez
02cbd7729b Match existing table 2026-01-12 13:15:26 -08:00
Joel Jeremy Marquez
806d54720d Fix lint errors 2026-01-12 10:00:55 -08:00
Joel Jeremy Marquez
5a8b3a0acc Update gaps 2026-01-12 09:52:27 -08:00
Joel Jeremy Marquez
1cabe8ab6c Cleanup 2026-01-12 09:52:23 -08:00
Joel Jeremy Marquez
f378d75727 Fix typecheck and lint errors 2026-01-12 09:48:04 -08:00
Joel Jeremy Marquez
1b02460456 Release notes 2026-01-12 09:48:04 -08:00
Joel Jeremy Marquez
1db3a43c56 Reuse BalanceMovementMenu 2026-01-12 09:48:04 -08:00
Joel Jeremy Marquez
eb5b5231dc Revert EnvelopeBudgetComponents and BalanceMenu 2026-01-12 09:47:27 -08:00
autofix-ci[bot]
475c4ef521 [autofix.ci] apply automated fixes 2026-01-12 09:47:27 -08:00
Joel Jeremy Marquez
04301e9bc8 Fix lint error 2026-01-12 09:47:27 -08:00
Joel Jeremy Marquez
d6f313b06e Fix lint 2026-01-12 09:47:27 -08:00
Joel Jeremy Marquez
165b6d3b17 Break up components into different files 2026-01-12 09:47:27 -08:00
Joel Jeremy Marquez
7b7d9d2ddb Blur on escape 2026-01-12 09:47:27 -08:00
Joel Jeremy Marquez
3db6af520c Typecheck + update BalanceMenuto render submenus on caller 2026-01-12 09:47:27 -08:00
Joel Jeremy Marquez
a24d180002 Spent and balance cell action on enter 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
4f6d5b5544 Navigate table cells with arrow keys 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
8bfb3bbf1d Fix border on Add group button row 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
72124c772f Fix styles 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
3f827b8fea Remove popover sizes 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
f8391c6e45 Implement category and group rename 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
58fa711f4a Column resizer background 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
29c52a830c More updates 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
e63e68e764 Experimental BudgetCategoriesV2 2026-01-12 09:47:09 -08:00
Joel Jeremy Marquez
e8917275fa Wrong PR number 2026-01-12 09:22:25 -08:00
Joel Jeremy Marquez
cad15ed4f9 Release notes 2026-01-12 09:22:25 -08:00
29 changed files with 3091 additions and 67 deletions

View File

@@ -6,6 +6,7 @@ import React, {
type CSSProperties,
useCallback,
} from 'react';
import { useFocusVisible } from 'react-aria';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -49,6 +50,7 @@ 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']>
>(
@@ -87,7 +89,9 @@ export function NotesButton({
...(isOpen && { color: theme.buttonNormalText }),
'&:hover': { opacity: 1 },
}),
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
!hasNotes && !isOpen && !isFocusVisible && !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': {

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ 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';
@@ -24,6 +25,7 @@ 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';
@@ -82,6 +84,7 @@ 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);
@@ -205,15 +208,11 @@ export function BudgetTable(props: BudgetTableProps) {
setShowHiddenCategoriesPef(!showHiddenCategories);
};
const toggleHiddenCategories = () => {
onToggleHiddenCategories();
};
const expandAllCategories = () => {
const onExpandAllCategories = () => {
onCollapse([]);
};
const collapseAllCategories = () => {
const onCollapseAllCategories = () => {
onCollapse(categoryGroups.map(g => g.id));
};
@@ -262,45 +261,62 @@ export function BudgetTable(props: BudgetTableProps) {
monthBounds={monthBounds}
type={type}
>
<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}
<SchedulesProvider query={schedulesQuery}>
{!budgetTableV2Enabled && (
<>
<BudgetTotals
toggleHiddenCategories={onToggleHiddenCategories}
expandAllCategories={onExpandAllCategories}
collapseAllCategories={onCollapseAllCategories}
/>
</SchedulesProvider>
</View>
</View>
<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
onBudgetAction={onBudgetAction}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
onToggleHiddenCategories={onToggleHiddenCategories}
onExpandAllCategories={onExpandAllCategories}
onCollapseAllCategories={onCollapseAllCategories}
/>
</View>
)}
</SchedulesProvider>
</MonthsProvider>
</View>
);

View File

@@ -0,0 +1,229 @@
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

@@ -0,0 +1,240 @@
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

@@ -0,0 +1,62 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,213 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,153 @@
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

@@ -0,0 +1,86 @@
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,6 +11,7 @@ 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) {
@@ -78,8 +79,12 @@ 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(
'left',
budgetTableV2Enabled ? 'alt+left' : 'left',
() => {
_onMonthSelect(monthUtils.prevMonth(startMonth));
},
@@ -90,7 +95,7 @@ const DynamicBudgetTable = ({
[_onMonthSelect, startMonth],
);
useHotkeys(
'right',
budgetTableV2Enabled ? 'alt+right' : 'right',
() => {
_onMonthSelect(monthUtils.nextMonth(startMonth));
},

View File

@@ -0,0 +1,99 @@
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

@@ -0,0 +1,98 @@
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

@@ -0,0 +1,136 @@
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

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

View File

@@ -27,7 +27,6 @@ type CoverMenuProps = {
initialAmount?: IntegerAmount | null;
categoryId?: CategoryEntity['id'];
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
onClose: () => void;
};
export function CoverMenu({
@@ -35,7 +34,6 @@ export function CoverMenu({
initialAmount = 0,
categoryId,
onSubmit,
onClose,
}: CoverMenuProps) {
const { t } = useTranslation();
@@ -61,7 +59,6 @@ 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}
onClose={() => setBalanceMenuOpen(false)}
onSelect={() => setBalanceMenuOpen(false)}
/>
</Popover>
</Field>

View File

@@ -22,7 +22,6 @@ type TransferMenuProps = {
initialAmount?: IntegerAmount | null;
showToBeBudgeted?: boolean;
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
onClose: () => void;
};
export function TransferMenu({
@@ -30,7 +29,6 @@ export function TransferMenu({
initialAmount = 0,
showToBeBudgeted,
onSubmit,
onClose,
}: TransferMenuProps) {
const { grouped: originalCategoryGroups } = useCategories();
const filteredCategoryGroups = useMemo(() => {
@@ -54,7 +52,6 @@ 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 { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations';
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,
} = useCategoryActions();
} = useCategoryMutations();
if (!initialized || !categoryGroups) {
return null;

View File

@@ -202,6 +202,15 @@ 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,6 +3,7 @@ import React, {
type ComponentPropsWithoutRef,
type ReactNode,
type CSSProperties,
Fragment,
} from 'react';
import { styles } from '@actual-app/components/styles';
@@ -43,10 +44,15 @@ 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' ? (
<>{children({ type, name: fullSheetName, value: sheetValue })}</>
<Fragment key={key}>
{children({ type, name: fullSheetName, value: sheetValue })}
</Fragment>
) : (
<CellValueText
key={key}
type={type}
name={fullSheetName}
value={sheetValue}

View File

@@ -0,0 +1,228 @@
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,6 +9,9 @@ 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,7 +4,10 @@ export type FeatureFlag =
| 'actionTemplating'
| 'formulaMode'
| 'currency'
| 'crossoverReport';
| 'crossoverReport'
| 'plugins'
| 'forceReload'
| 'budgetTableV2';
/**
* Cross-device preferences. These sync across devices when they are changed.

View File

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