Add Notes to Monthly Budget Cell (#6620)

* Add Notes to Monthly Budget Cell
Changed Modal menus layout to follow month menu on mobile

* Fixed rebase errors

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6620

* Addressed youngcw's comments (notes id format, notesButton defaultColor and modal layout)

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6620

* Updated mobile budget menu modal page model

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
This commit is contained in:
erwannc
2026-03-19 01:42:30 +01:00
committed by GitHub
parent a43b6f5c47
commit 0793eb5927
17 changed files with 429 additions and 158 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -5,6 +5,7 @@ export class BudgetMenuModal {
readonly locator: Locator;
readonly heading: Locator;
readonly budgetAmountInput: Locator;
readonly actionsButton: Locator;
readonly copyLastMonthBudgetButton: Locator;
readonly setTo3MonthAverageButton: Locator;
readonly setTo6MonthAverageButton: Locator;
@@ -17,6 +18,9 @@ export class BudgetMenuModal {
this.heading = locator.getByRole('heading');
this.budgetAmountInput = locator.getByTestId('amount-input');
this.actionsButton = locator.getByRole('button', {
name: 'Actions',
});
this.copyLastMonthBudgetButton = locator.getByRole('button', {
name: "Copy last month's budget",
});
@@ -38,6 +42,10 @@ export class BudgetMenuModal {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
async showActions() {
await this.actionsButton.click();
}
async setBudgetAmount(newAmount: string) {
await this.budgetAmountInput.fill(newAmount);
await this.budgetAmountInput.blur();
@@ -45,22 +53,27 @@ export class BudgetMenuModal {
}
async copyLastMonthBudget() {
await this.showActions();
await this.copyLastMonthBudgetButton.click();
}
async setTo3MonthAverage() {
await this.showActions();
await this.setTo3MonthAverageButton.click();
}
async setTo6MonthAverage() {
await this.showActions();
await this.setTo6MonthAverageButton.click();
}
async setToYearlyAverage() {
await this.showActions();
await this.setToYearlyAverageButton.click();
}
async applyBudgetTemplate() {
await this.showActions();
await this.applyBudgetTemplateButton.click();
}
}

View File

@@ -25,6 +25,7 @@ import { IncomeMenu } from './IncomeMenu';
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
import { makeAmountGrey } from '@desktop-client/components/budget/util';
import { NotesButton } from '@desktop-client/components/NotesButton';
import {
CellValue,
CellValueText,
@@ -281,85 +282,100 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
}}
>
{!editing && (
<View
className={`hover-expand ${budgetMenuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 1,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<Button
variant="bare"
onPress={() => {
resetBudgetPosition(2, -4);
setBudgetMenuOpen(true);
}}
<>
<View
style={{
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
padding: 3,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
<NotesButton
id={`${category.id}-${month}`}
defaultColor={theme.pageTextLight}
/>
</Button>
<Popover
triggerRef={budgetMenuTriggerRef}
placement="bottom left"
isOpen={budgetMenuOpen}
onOpenChange={() => setBudgetMenuOpen(false)}
style={{ width: 200 }}
isNonModal
{...budgetPosition}
</View>
<View
className={`hover-expand ${budgetMenuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 1,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: t(`Budget set to last month's budget.`),
});
<Button
variant="bare"
onPress={() => {
resetBudgetPosition(2, -4);
setBudgetMenuOpen(true);
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
style={{
padding: 3,
}}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
/>
</Button>
<Popover
triggerRef={budgetMenuTriggerRef}
placement="bottom left"
isOpen={budgetMenuOpen}
onOpenChange={() => setBudgetMenuOpen(false)}
style={{ width: 200 }}
isNonModal
{...budgetPosition}
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: t(`Budget set to last month's budget.`),
});
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: t(
'Budget set to {{numberOfMonths}}-month average.',
{ numberOfMonths },
),
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: t(`Budget template applied.`),
});
}}
/>
</Popover>
</View>
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: t(
'Budget set to {{numberOfMonths}}-month average.',
{ numberOfMonths },
),
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: t(`Budget template applied.`),
});
}}
/>
</Popover>
</View>
</>
)}
<EnvelopeSheetCell
name="budget"

View File

@@ -15,6 +15,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { t } from 'i18next';
import * as monthUtils from 'loot-core/shared/months';
@@ -25,6 +26,7 @@ import { BudgetMenu } from './BudgetMenu';
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
import { makeAmountGrey } from '@desktop-client/components/budget/util';
import { NotesButton } from '@desktop-client/components/NotesButton';
import {
CellValue,
CellValueText,
@@ -261,77 +263,96 @@ export const CategoryMonth = memo(function CategoryMonth({
}}
>
{!editing && (
<View
className={`hover-expand ${menuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 0,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<Button
ref={triggerRef}
variant="bare"
onPress={() => setMenuOpen(true)}
<>
<View
style={{
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
padding: 3,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
<NotesButton
id={`${category.id}-${month}`}
defaultColor={theme.pageTextLight}
/>
</Button>
<Popover
triggerRef={triggerRef}
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
placement="bottom start"
</View>
<View
className={`hover-expand ${menuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 0,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `Budget set to last month's budget.`,
});
<Button
ref={triggerRef}
variant="bare"
onPress={() => setMenuOpen(true)}
style={{
padding: 3,
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
/>
</Button>
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `Budget set to ${numberOfMonths}-month average.`,
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `Budget template applied.`,
});
}}
/>
</Popover>
</View>
<Popover
triggerRef={triggerRef}
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
placement="bottom start"
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: t(`Budget set to last month's budget.`),
});
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: t(
'Budget set to {{numberOfMonths}}-month average.',
{ numberOfMonths },
),
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: t(`Budget template applied.`),
});
}}
/>
</Popover>
</View>
</>
)}
<TrackingSheetCell
name="budget"

View File

@@ -7,6 +7,8 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { AutoTextSize } from 'auto-text-size';
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import type { CategoryEntity } from 'loot-core/types/models';
import { getColumnWidth, PILL_STYLE } from './BudgetTable';
@@ -15,6 +17,7 @@ import { makeAmountGrey } from '@desktop-client/components/budget/util';
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useNotes } from '@desktop-client/hooks/useNotes';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useUndo } from '@desktop-client/hooks/useUndo';
@@ -43,6 +46,7 @@ export function BudgetCell<
...props
}: BudgetCellProps<SheetFieldName>) {
const { t } = useTranslation();
const locale = useLocale();
const columnWidth = getColumnWidth();
const dispatch = useDispatch();
const format = useFormat();
@@ -50,6 +54,31 @@ export function BudgetCell<
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
const categoryNotes = useNotes(category.id);
const onSaveNotes = useCallback(async (id: string, notes: string) => {
await send('notes-save', { id, note: notes });
}, []);
const onEditNotes = useCallback(
(id: string, month: string) => {
dispatch(
pushModal({
modal: {
name: 'notes',
options: {
id,
name:
category.name +
' - ' +
monthUtils.format(month, "MMMM ''yy", locale),
onSave: onSaveNotes,
},
},
}),
);
},
[category.name, locale, dispatch, onSaveNotes],
);
const onOpenCategoryBudgetMenu = useCallback(() => {
const modalBudgetType = budgetType === 'envelope' ? 'envelope' : 'tracking';
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const;
@@ -60,6 +89,7 @@ export function BudgetCell<
options: {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
@@ -114,6 +144,7 @@ export function BudgetCell<
month,
onBudgetAction,
showUndoNotification,
onEditNotes,
format,
]);

View File

@@ -2,10 +2,17 @@ import React, { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import {
SvgCheveronDown,
SvgCheveronUp,
} from '@actual-app/components/icons/v1';
import { SvgNotesPaper } from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { t } from 'i18next';
import * as Platform from 'loot-core/shared/platform';
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
@@ -19,14 +26,16 @@ import {
ModalTitle,
} from '@desktop-client/components/common/Modal';
import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput';
import { Notes } from '@desktop-client/components/Notes';
import { useCategory } from '@desktop-client/hooks/useCategory';
import { useNotes } from '@desktop-client/hooks/useNotes';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
type EnvelopeBudgetMenuModalProps = Omit<
Extract<ModalType, { name: 'envelope-budget-menu' }>['options'],
'month'
>;
type EnvelopeBudgetMenuModalProps = Extract<
ModalType,
{ name: 'envelope-budget-menu' }
>['options'];
export function EnvelopeBudgetMenuModal({
categoryId,
@@ -34,7 +43,17 @@ export function EnvelopeBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onEditNotes,
month,
}: EnvelopeBudgetMenuModalProps) {
const buttonStyle: CSSProperties = {
...styles.mediumText,
height: styles.mobileMinHeight,
color: theme.formLabelText,
// Adjust based on desired number of buttons per row.
flexBasis: '100%',
};
const defaultMenuItemStyle: CSSProperties = {
...styles.mobileMenuItem,
color: theme.menuItemText,
@@ -48,10 +67,24 @@ export function EnvelopeBudgetMenuModal({
const { data: category } = useCategory(categoryId);
const [amountFocused, setAmountFocused] = useState(false);
const notesId = category ? `${category.id}-${month}` : '';
const originalNotes = useNotes(notesId) ?? '';
const _onUpdateBudget = (amount: number) => {
onUpdateBudget?.(amountToInteger(amount));
};
const [showMore, setShowMore] = useState(false);
const onShowMore = () => {
setShowMore(!showMore);
};
const _onEditNotes = () => {
if (category && month) {
onEditNotes?.(`${category.id}-${month}`, month);
}
};
useEffect(() => {
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
@@ -76,7 +109,6 @@ export function EnvelopeBudgetMenuModal({
style={{
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}
>
<Text
@@ -106,12 +138,71 @@ export function EnvelopeBudgetMenuModal({
data-testid="budget-amount"
/>
</View>
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
<View
style={{
display: showMore ? 'none' : undefined,
overflowY: 'auto',
flex: 1,
}}
>
<Notes
notes={originalNotes.length > 0 ? originalNotes : t('No notes')}
editable={false}
focused={false}
getStyle={() => ({
borderRadius: 6,
...(originalNotes.length === 0 && {
justifySelf: 'center',
alignSelf: 'center',
color: theme.pageTextSubdued,
}),
})}
/>
</View>
<View
style={{
display: showMore ? 'none' : undefined,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignContent: 'space-between',
}}
>
<Button style={buttonStyle} onPress={_onEditNotes}>
<SvgNotesPaper
width={20}
height={20}
style={{ paddingRight: 5 }}
/>
<Trans>Edit notes</Trans>
</Button>
</View>
<View>
<Button variant="bare" style={buttonStyle} onPress={onShowMore}>
{!showMore ? (
<SvgCheveronUp
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
) : (
<SvgCheveronDown
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
)}
<Trans>Actions</Trans>
</Button>
</View>
{showMore && (
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
)}
</>
)}
</Modal>

View File

@@ -2,10 +2,17 @@ import React, { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import {
SvgCheveronDown,
SvgCheveronUp,
} from '@actual-app/components/icons/v1';
import { SvgNotesPaper } from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { t } from 'i18next';
import * as Platform from 'loot-core/shared/platform';
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
@@ -19,14 +26,16 @@ import {
ModalTitle,
} from '@desktop-client/components/common/Modal';
import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput';
import { Notes } from '@desktop-client/components/Notes';
import { useCategory } from '@desktop-client/hooks/useCategory';
import { useNotes } from '@desktop-client/hooks/useNotes';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { trackingBudget } from '@desktop-client/spreadsheet/bindings';
type TrackingBudgetMenuModalProps = Omit<
Extract<ModalType, { name: 'tracking-budget-menu' }>['options'],
'month'
>;
type TrackingBudgetMenuModalProps = Extract<
ModalType,
{ name: 'tracking-budget-menu' }
>['options'];
export function TrackingBudgetMenuModal({
categoryId,
@@ -34,6 +43,8 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
const defaultMenuItemStyle: CSSProperties = {
...styles.mobileMenuItem,
@@ -42,16 +53,38 @@ export function TrackingBudgetMenuModal({
borderTop: `1px solid ${theme.pillBorder}`,
};
const buttonStyle: CSSProperties = {
...styles.mediumText,
height: styles.mobileMinHeight,
color: theme.formLabelText,
// Adjust based on desired number of buttons per row.
flexBasis: '100%',
};
const budgeted = useTrackingSheetValue(
trackingBudget.catBudgeted(categoryId),
);
const { data: category } = useCategory(categoryId);
const notesId = category ? `${category.id}-${month}` : '';
const originalNotes = useNotes(notesId) ?? '';
const [amountFocused, setAmountFocused] = useState(false);
const _onUpdateBudget = (amount: number) => {
onUpdateBudget?.(amountToInteger(amount));
};
const _onEditNotes = () => {
if (category && month) {
onEditNotes?.(`${category.id}-${month}`, month);
}
};
const [showMore, setShowMore] = useState(false);
const onShowMore = () => {
setShowMore(!showMore);
};
useEffect(() => {
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
@@ -76,7 +109,6 @@ export function TrackingBudgetMenuModal({
style={{
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}
>
<Text
@@ -106,12 +138,71 @@ export function TrackingBudgetMenuModal({
data-testid="budget-amount"
/>
</View>
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
<View
style={{
display: showMore ? 'none' : undefined,
overflowY: 'auto',
flex: 1,
}}
>
<Notes
notes={originalNotes.length > 0 ? originalNotes : t('No notes')}
editable={false}
focused={false}
getStyle={() => ({
borderRadius: 6,
...(originalNotes.length === 0 && {
justifySelf: 'center',
alignSelf: 'center',
color: theme.pageTextSubdued,
}),
})}
/>
</View>
<View
style={{
display: showMore ? 'none' : undefined,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignContent: 'space-between',
}}
>
<Button style={buttonStyle} onPress={_onEditNotes}>
<SvgNotesPaper
width={20}
height={20}
style={{ paddingRight: 5 }}
/>
<Trans>Edit notes</Trans>
</Button>
</View>
<View>
<Button variant="bare" style={buttonStyle} onPress={onShowMore}>
{!showMore ? (
<SvgCheveronUp
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
) : (
<SvgCheveronDown
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
)}
<Trans>Actions</Trans>
</Button>
</View>
{showMore && (
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
)}
</>
)}
</Modal>

View File

@@ -334,6 +334,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
| {
@@ -345,6 +346,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
| {