[AI] Improved context menu (#8106)

* Context Menu is implemented

* allow Document and Window in ref event listener hook

* did stupid examples to showcase api usage

* recreated TransactionMenu using new context action

* fixed styling and removed excess calls to useContextMenuAction

* removed extra call

* extra imports

* started converting files to useContextMenu

* fixed context menus in reports

* linting and formatting

* deleted TransactionMenu

* release notes

* fixed coderabbit

* light context menu refactor

* added nested contexts for state and actions

* refactored useContextMenuAction

* [autofix.ci] apply automated fixes

* removed log

* updated contextMenuSlice

* removed package.json change

* filter => some

* tried to improve test

* [AI] test: fix payees e2e right-click test by scoping row query to table

* linting

* [AI] chore: fix all linting issues from new context menu files

* fixed compiler

* fixed linting/formatting

* coderabbit fix

* [AI] fix(electron): connect to sync server on 127.0.0.1 instead of localhost

Node.js > 17 resolves localhost to ::1 (IPv6) by default, while the Electron
sync server binds explicitly to 127.0.0.1 (IPv4). This caused setServerUrl to
fail when the desktop app starts its internal sync server, trapping the user
in an onboarding loop between /config-server and /bootstrap.

This commit updates the URL to use 127.0.0.1 to match the server binding.

* [AI] Fix isPlaywright detection for Electron Playwright tests

The userAgent check was changed from includes('playwright') to
=== 'playwright', but the Electron main process appends ' playwright'
to the default userAgent (preserving the 'Electron' substring that
isElectron() relies on). Use includes() so the flag is detected
correctly in Electron e2e tests.

Also reverts the localhost→127.0.0.1 change which did not resolve the
VRT pixel diffs.

* re-added missing menu option

---------

Co-authored-by: Alec Bakholdin <alecbakholdin.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Alec Bakholdin
2026-07-02 17:44:03 +00:00
committed by GitHub
co-authored by Alec Bakholdin autofix-ci[bot]
parent 8b5b182dbc
commit 74c925aaf6
61 changed files with 1307 additions and 1311 deletions
+1 -1
View File
@@ -83,7 +83,7 @@
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
"fixStyle": "separate-type-imports"
}
],
"typescript/no-implied-eval": "error",
+1
View File
@@ -148,6 +148,7 @@ export function Menu<const NameType = string>({
return (
<View
role="menu"
className={className}
style={{ outline: 'none', borderRadius: 4, overflow: 'hidden', ...style }}
tabIndex={0}
@@ -56,6 +56,26 @@ test.describe('Accounts', () => {
await expect(page).toMatchThemeScreenshots();
});
test('right clicking an account in sidebar opens context menu', async () => {
await navigation.rightClickAccount('Roth IRA');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Rename' })).toBeVisible();
});
test('right clicking a transaction row opens context menu', async () => {
accountPage = await navigation.createAccount({
name: 'New Account',
offBudget: false,
balance: 100,
});
await accountPage.rightClickNthTransaction(0);
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('shift-click range selection skips hidden reconciled transactions', async () => {
accountPage = await navigation.createAccount({
name: 'Range Select',
@@ -65,6 +65,29 @@ test.describe('Budget', () => {
expect(await accountPage.accountName.textContent()).toMatch('All Accounts');
await page.getByRole('button', { name: 'Back' }).click();
});
test('right clicking a category opens context menu', async () => {
await budgetPage.rightClickCategory(1);
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Rename' })).toBeVisible();
});
test('right clicking a category group opens context menu', async () => {
await budgetPage.rightClickCategoryGroup('Usual Expenses');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Rename' })).toBeVisible();
});
test('right clicking budget name opens context menu', async () => {
await page.getByTestId('budget-name').click({ button: 'right' });
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(
menu.getByRole('button', { name: 'Switch file' }),
).toBeVisible();
});
});
test.describe('Budget scroll position', () => {
@@ -275,6 +275,10 @@ export class AccountPage {
await input.selectText();
}
}
async rightClickNthTransaction(index: number) {
await this.transactionTableRow.nth(index).click({ button: 'right' });
}
}
class FilterTooltip {
@@ -238,4 +238,19 @@ export class BudgetPage {
await this.page.getByRole('button', { name: 'Transfer' }).click();
}
async rightClickCategory(idx: number) {
await this.budgetTable
.getByTestId('row')
.nth(idx)
.getByTestId('category-name')
.click({ button: 'right' });
}
async rightClickCategoryGroup(name: string) {
// Assuming category groups have a specific text or role, or we can just find by text
await this.budgetTable
.getByText(name, { exact: true })
.click({ button: 'right' });
}
}
@@ -186,4 +186,14 @@ export class Navigation {
async clickOnNoServer() {
await this.page.getByRole('button', { name: 'No server' }).click();
}
async rightClickAccount(accountName: string) {
await this.page
.getByRole('link', { name: new RegExp(`^${accountName}`) })
.click({ button: 'right' });
}
async rightClickBudgetName() {
await this.page.getByTestId('budget-name').click({ button: 'right' });
}
}
@@ -13,4 +13,12 @@ export class PayeesPage {
async searchFor(payeeName: string) {
await this.searchBox.fill(payeeName);
}
async rightClickNthPayee(idx: number) {
await this.page
.getByTestId('table')
.getByTestId('row')
.nth(idx)
.click({ button: 'right' });
}
}
@@ -124,4 +124,10 @@ export class ReportsPage {
.getByRole('heading')
.allTextContents();
}
async rightClickReportCard(title: string | RegExp) {
await this.pageContent
.getByRole('button', { name: title })
.click({ button: 'right' });
}
}
@@ -39,4 +39,12 @@ export class RulesPage {
async searchFor(text: string) {
await this.searchBox.fill(text);
}
async rightClickNthRule(index: number) {
await this.page
.getByTestId('table')
.getByTestId('row')
.nth(index)
.click({ button: 'right' });
}
}
@@ -72,4 +72,8 @@ export class SchedulesPage {
await actions.getByRole('button').click();
await this.page.getByRole('button', { name: actionName }).click();
}
async rightClickNthSchedule(index: number) {
await this.getNthScheduleRow(index).click({ button: 'right' });
}
}
@@ -57,4 +57,14 @@ test.describe('Payees', () => {
// Assert it is visible
await expect(noPayeesMessage).toBeVisible();
});
test('right clicking a payee opens context menu', async () => {
await payeesPage.searchFor('Fast Internet');
await payeesPage.rightClickNthPayee(0);
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(
menu.getByRole('button', { name: 'Create rule' }),
).toBeVisible();
});
});
@@ -47,6 +47,13 @@ test.describe('Reports', () => {
await expect(page).toMatchThemeScreenshots();
});
test('right clicking a report card opens context menu', async () => {
await reportsPage.rightClickReportCard('Net Worth');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Rename' })).toBeVisible();
});
test('loads net worth graph and checks visuals', async () => {
await reportsPage.goToNetWorthPage();
await expect(page).toMatchThemeScreenshots();
@@ -137,4 +137,12 @@ test.describe('Rules', () => {
await expect(secondSplitTransaction.debit).toHaveText('10.00');
await expect(secondSplitTransaction.category).toHaveText('Food');
});
test('right clicking a rule opens context menu', async () => {
await rulesPage.searchFor('Fast Internet');
await rulesPage.rightClickNthRule(0);
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Edit' })).toBeVisible();
});
});
@@ -164,4 +164,19 @@ test.describe('Schedules', () => {
}
await expect(page).toMatchThemeScreenshots();
});
test('right clicking a schedule opens context menu', async () => {
const scheduleEditModal = await schedulesPage.addNewSchedule();
await scheduleEditModal.fill({
payee: 'Home Depot',
account: 'HSBC',
amount: 25,
});
await scheduleEditModal.add();
await schedulesPage.rightClickNthSchedule(2);
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Complete' })).toBeVisible();
});
});
+1
View File
@@ -91,6 +91,7 @@
"#auth/AuthProvider": "./src/auth/AuthProvider.tsx",
"#auth/ProtectedRoute": "./src/auth/ProtectedRoute.tsx",
"#auth/*": "./src/auth/*.ts",
"#contextmenu/*": "./src/contextmenu/*.ts",
"#style/theme": "./src/style/theme.tsx",
"#style/*": "./src/style/*.ts",
"#notes/*": "./src/notes/*.tsx",
@@ -0,0 +1,80 @@
import { useRef } from 'react';
import { Menu } from '@actual-app/components/menu';
import type { MenuItem } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { theme } from '@actual-app/components/theme';
import { closeContextMenu } from '#contextmenu/contextMenuSlice';
import { useRefEventListener } from '#hooks/useRefEventListener';
import { useDispatch, useSelector } from '#redux';
export function ContextMenu() {
const dispatch = useDispatch();
const { isOpen, position, items } = useSelector(state => state.contextMenu);
const popoverRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLDivElement | null>(null);
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
dispatch(closeContextMenu());
}
}
// Handle left-clicks and right-clicks to DISMISS the menu
useRefEventListener(document, 'pointerdown', (e: PointerEvent) => {
// Close the menu if we click anywhere outside the popover DOM element
if (
isOpen &&
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
handleOpenChange(false);
}
});
function handleMenuSelect(itemName: string) {
for (const item of items) {
if (typeof item === 'object' && item.onClick && item.name === itemName) {
item.onClick();
break;
}
}
handleOpenChange(false);
}
return (
<>
{/* THE INVISIBLE ANCHOR:
A 0x0 pixel real DOM node that follows your right-clicks.
*/}
<div
ref={triggerRef}
style={{
position: 'fixed',
left: position.x,
top: position.y,
width: 0,
height: 0,
pointerEvents: 'none', // Prevents it from interfering with user clicks
}}
/>
<Popover
ref={popoverRef}
triggerRef={triggerRef}
isOpen={isOpen && !!items.length}
onOpenChange={handleOpenChange}
placement="bottom start"
isNonModal
style={{ width: 200, margin: 1 }}
>
<Menu
onMenuSelect={handleMenuSelect}
items={items as MenuItem[]}
style={{ backgroundColor: theme.menuBackground }}
/>
</Popover>
</>
);
}
@@ -25,6 +25,7 @@ import { UserAccessPage } from './admin/UserAccess/UserAccessPage';
import { UserDirectoryPage } from './admin/UserDirectory/UserDirectoryPage';
import { BankSyncStatus } from './BankSyncStatus';
import { CommandBar } from './CommandBar';
import { ContextMenu } from './ContextMenu';
import { EnableBankingCallback } from './EnableBankingCallback';
import { FeatureErrorFallback } from './FeatureErrorFallback';
import { GlobalKeys } from './GlobalKeys';
@@ -198,6 +199,7 @@ export function FinancesApp() {
<RouterBehaviors />
<GlobalKeys />
<CommandBar />
<ContextMenu />
<View
style={{
flexDirection: 'row',
@@ -5,8 +5,6 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { TextOneLine } from '@actual-app/components/text-one-line';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -65,9 +63,27 @@ export function SidebarCategory({
const categoryExpandedState = categoryExpandedStatePref ?? 0;
const temporary = category.id === 'new';
const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } =
useContextMenu();
const triggerRef = useRef(null);
const { handleContextMenu } = useContextMenu({
triggerRef,
items: [
{
name: 'rename',
text: t('Rename'),
onClick: () => onEditName(category.id),
},
!categoryGroup?.hidden && {
name: 'toggle-visibility',
text: category.hidden ? t('Show') : t('Hide'),
onClick: () => onSave({ ...category, hidden: !category.hidden }),
},
{
name: 'delete',
text: t('Delete'),
onClick: () => onDelete(category.id),
},
],
});
const displayed = (
<View
@@ -81,7 +97,6 @@ export function SidebarCategory({
height: 20,
}}
ref={triggerRef}
onContextMenu={handleContextMenu}
>
<TextOneLine data-testid="category-name">{category.name}</TextOneLine>
<View style={{ flexShrink: 0, marginLeft: 5 }}>
@@ -89,10 +104,7 @@ export function SidebarCategory({
variant="bare"
className="hover-visible"
style={{ color: 'currentColor', padding: 3 }}
onPress={() => {
resetPosition();
setMenuOpen(true);
}}
onPress={handleContextMenu}
>
<SvgCheveronDown
width={14}
@@ -100,37 +112,6 @@ export function SidebarCategory({
style={{ color: 'currentColor' }}
/>
</Button>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
style={{ width: 200, margin: 1 }}
isNonModal
{...position}
>
<Menu
onMenuSelect={type => {
if (type === 'rename') {
onEditName(category.id);
} else if (type === 'delete') {
onDelete(category.id);
} else if (type === 'toggle-visibility') {
onSave({ ...category, hidden: !category.hidden });
}
setMenuOpen(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>
</View>
<SidebarCategoryButtons
category={category}
@@ -7,7 +7,6 @@ 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 { 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';
@@ -70,9 +69,48 @@ export function SidebarGroup({
const temporary = group.id === 'new';
const canSortCategories =
!!onSortCategories && (group.categories?.length ?? 0) > 1;
const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } =
useContextMenu();
const triggerRef = useRef(null);
const { handleContextMenu } = useContextMenu({
triggerRef,
items: [
onEdit && {
name: 'rename',
text: t('Rename'),
onClick: () => onEdit(group.id),
},
onSave && {
name: 'toggle-visibility',
text: group.hidden ? t('Show') : t('Hide'),
onClick: () => onSave({ ...group, hidden: !group.hidden }),
hidden: group.is_income,
},
onDelete && {
name: 'delete',
text: t('Delete'),
onClick: () => onDelete(group.id),
},
canSortCategories && Menu.line,
canSortCategories && {
name: 'sort-asc',
text: t('Sort A to Z'),
onClick: () => onSortCategories(group.id, 'asc'),
},
canSortCategories && {
name: 'sort-desc',
text: t('Sort Z to A'),
onClick: () => onSortCategories(group.id, 'desc'),
},
isGoalTemplatesEnabled &&
onApplyBudgetTemplatesInGroup && {
name: 'apply-multiple-category-template',
text: t('Overwrite with templates'),
onClick: () =>
onApplyBudgetTemplatesInGroup(
group.categories.filter(c => !c.hidden).map(c => c.id),
),
},
],
});
const displayed = (
<View
@@ -87,7 +125,6 @@ export function SidebarGroup({
onClick={() => {
onToggleCollapse(group.id);
}}
onContextMenu={handleContextMenu}
>
{!dragPreview && (
<SvgExpandArrow
@@ -119,70 +156,11 @@ export function SidebarGroup({
<Button
variant="bare"
className="hover-visible"
onPress={() => {
resetPosition();
setMenuOpen(true);
}}
style={{ padding: 3 }}
onPress={handleContextMenu}
>
<SvgCheveronDown width={14} height={14} />
</Button>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
style={{ width: 200, margin: 1 }}
isNonModal
{...position}
>
<Menu
onMenuSelect={type => {
if (type === 'rename') {
onEdit(group.id);
} else if (type === 'delete') {
onDelete(group.id);
} else if (type === 'toggle-visibility') {
onSave({ ...group, hidden: !group.hidden });
} else if (type === 'apply-multiple-category-template') {
onApplyBudgetTemplatesInGroup?.(
group.categories.filter(c => !c.hidden).map(c => c.id),
);
} else if (type === 'sort-asc') {
onSortCategories?.(group.id, 'asc');
} else if (type === 'sort-desc') {
onSortCategories?.(group.id, 'desc');
}
setMenuOpen(false);
}}
items={[
{ name: 'rename', text: t('Rename') },
!group.is_income && {
name: 'toggle-visibility',
text: group.hidden ? t('Show') : t('Hide'),
},
onDelete && { name: 'delete', text: t('Delete') },
canSortCategories && Menu.line,
canSortCategories && {
name: 'sort-asc',
text: t('Sort A to Z'),
},
canSortCategories && {
name: 'sort-desc',
text: t('Sort Z to A'),
},
...(isGoalTemplatesEnabled
? [
{
name: 'apply-multiple-category-template',
text: t('Overwrite with templates'),
},
]
: []),
]}
/>
</Popover>
</View>
<View style={{ flex: 1 }} />
<View
@@ -1,5 +1,5 @@
import React, { memo, useRef } from 'react';
import type { ComponentProps, CSSProperties } from 'react';
import React, { memo, useRef, useState } from 'react';
import type { ComponentProps, CSSProperties, MouseEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -23,7 +23,6 @@ import { CellValue, CellValueText } from '#components/spreadsheet/CellValue';
import { Field, Row, SheetCell } from '#components/table';
import type { SheetCellProps } from '#components/table';
import { useCategoryScheduleGoalTemplateIndicator } from '#hooks/useCategoryScheduleGoalTemplateIndicator';
import { useContextMenu } from '#hooks/useContextMenu';
import { useFormat } from '#hooks/useFormat';
import { useNavigate } from '#hooks/useNavigate';
import { useSheetName } from '#hooks/useSheetName';
@@ -206,20 +205,41 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
const budgetMenuTriggerRef = useRef(null);
const balanceMenuTriggerRef = useRef(null);
const {
setMenuOpen: setBudgetMenuOpen,
menuOpen: budgetMenuOpen,
handleContextMenu: handleBudgetContextMenu,
resetPosition: resetBudgetPosition,
position: budgetPosition,
} = useContextMenu();
const {
setMenuOpen: setBalanceMenuOpen,
menuOpen: balanceMenuOpen,
handleContextMenu: handleBalanceContextMenu,
resetPosition: resetBalancePosition,
position: balancePosition,
} = useContextMenu();
const [budgetMenuOpen, setBudgetMenuOpen] = useState(false);
const [budgetPosition, setBudgetPosition] = useState({
crossOffset: 0,
offset: 0,
});
const resetBudgetPosition = (crossOffset = 0, offset = 0) =>
setBudgetPosition({ crossOffset, offset });
const handleBudgetContextMenu = (e: MouseEvent) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
setBudgetPosition({
crossOffset: e.clientX - rect.left,
offset: e.clientY - rect.bottom,
});
setBudgetMenuOpen(true);
};
const [balanceMenuOpen, setBalanceMenuOpen] = useState(false);
const [balancePosition, setBalancePosition] = useState({
crossOffset: 0,
offset: 0,
});
const resetBalancePosition = (crossOffset = 0, offset = 0) =>
setBalancePosition({ crossOffset, offset });
const handleBalanceContextMenu = (e: MouseEvent) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
setBalancePosition({
crossOffset: e.clientX - rect.left,
offset: e.clientY - rect.bottom,
});
setBalanceMenuOpen(true);
};
const onMenuAction = (...args: Parameters<typeof onBudgetAction>) => {
onBudgetAction(...args);
@@ -566,13 +586,23 @@ export function IncomeCategoryMonth({
onBudgetAction,
}: CategoryMonthProps) {
const incomeMenuTriggerRef = useRef(null);
const {
setMenuOpen: setIncomeMenuOpen,
menuOpen: incomeMenuOpen,
handleContextMenu: handleIncomeContextMenu,
resetPosition: resetIncomePosition,
position: incomePosition,
} = useContextMenu();
const [incomeMenuOpen, setIncomeMenuOpen] = useState(false);
const [incomePosition, setIncomePosition] = useState({
crossOffset: 0,
offset: 0,
});
const resetIncomePosition = (crossOffset = 0, offset = 0) =>
setIncomePosition({ crossOffset, offset });
const handleIncomeContextMenu = (e: MouseEvent) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
setIncomePosition({
crossOffset: e.clientX - rect.left,
offset: e.clientY - rect.bottom,
});
setIncomeMenuOpen(true);
};
return (
<View style={{ flex: 1 }}>
@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import type { CSSProperties, MouseEvent } from 'react';
import { Popover } from '@actual-app/components/popover';
import { View } from '@actual-app/components/view';
@@ -8,7 +8,6 @@ import { CoverMenu } from '#components/budget/envelope/CoverMenu';
import { useEnvelopeSheetValue } from '#components/budget/envelope/EnvelopeBudgetComponents';
import { HoldMenu } from '#components/budget/envelope/HoldMenu';
import { TransferMenu } from '#components/budget/envelope/TransferMenu';
import { useContextMenu } from '#hooks/useContextMenu';
import { useFormat } from '#hooks/useFormat';
import { envelopeBudget } from '#spreadsheet/bindings';
@@ -53,14 +52,20 @@ export function ToBudget({
);
}
const {
setMenuOpen,
menuOpen,
handleContextMenu,
resetPosition,
position,
asContextMenu,
} = useContextMenu();
const [menuOpen, setMenuOpen] = useState(false);
const [position, setPosition] = useState({ crossOffset: 0, offset: 0 });
const resetPosition = (crossOffset = 0, offset = 0) =>
setPosition({ crossOffset, offset });
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
setPosition({
crossOffset: e.clientX - rect.left,
offset: e.clientY - rect.bottom,
});
setMenuOpen(true);
};
return (
<>
@@ -80,7 +85,7 @@ export function ToBudget({
<Popover
triggerRef={triggerRef}
placement={asContextMenu ? 'bottom start' : 'bottom'}
placement="bottom"
isOpen={menuOpen}
onOpenChange={() => {
setMenuStep('actions');
@@ -8,8 +8,6 @@ import {
SvgBookmark,
SvgLightBulb,
} from '@actual-app/components/icons/v1';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import type { PayeeEntity } from '@actual-app/core/types/models';
@@ -125,8 +123,49 @@ export const PayeeTableRow = memo(
const { t } = useTranslation();
const triggerRef = useRef(null);
const { setMenuOpen, menuOpen, handleContextMenu, position } =
useContextMenu();
useContextMenu({
triggerRef,
items: [
{
name: 'delete',
text: t('Delete'),
onClick: () => onDelete(selectedIds),
hidden: payee.transfer_acct != null,
},
{
name: 'favorite',
text: payee.favorite ? t('Unfavorite') : t('Favorite'),
onClick: () =>
selectedIds.forEach(id =>
onUpdate(id, 'favorite', !payee.favorite),
),
hidden: payee.transfer_acct != null,
},
{
name: 'view-rules',
text: t('View rules'),
onClick: () => onViewRules(id),
hidden: !ruleCount,
},
{
name: 'create-rule',
text: t('Create rule'),
onClick: () => onCreateRule(id),
hidden: selectedIds.length !== 1,
},
{
name: 'learn',
text: payee.learn_categories
? t('Disable learning')
: t('Enable learning'),
onClick: () =>
selectedIds.forEach(id =>
onUpdate(id, 'learn_categories', !payee.learn_categories),
),
hidden: !isLearnCategoriesEnabled,
},
],
});
return (
<Row
@@ -149,68 +188,7 @@ export const PayeeTableRow = memo(
}}
data-focus-key={payee.id}
onMouseEnter={() => onHover && onHover(payee.id)}
onContextMenu={handleContextMenu}
>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
{...position}
style={{ width: 200, margin: 1 }}
isNonModal
>
<Menu
items={[
payee.transfer_acct == null && {
name: 'delete',
text: t('Delete'),
},
payee.transfer_acct == null && {
name: 'favorite',
text: payee.favorite ? t('Unfavorite') : t('Favorite'),
},
ruleCount > 0 && { name: 'view-rules', text: t('View rules') },
selectedIds.length === 1 && {
name: 'create-rule',
text: t('Create rule'),
},
isLearnCategoriesEnabled &&
(payee.learn_categories
? {
name: 'learn',
text: t('Disable learning'),
}
: { name: 'learn', text: t('Enable learning') }),
]}
onMenuSelect={name => {
switch (name) {
case 'delete':
onDelete(selectedIds);
break;
case 'favorite':
selectedIds.forEach(id => {
onUpdate(id, 'favorite', !payee.favorite);
});
break;
case 'learn':
selectedIds.forEach(id => {
onUpdate(id, 'learn_categories', !payee.learn_categories);
});
break;
case 'view-rules':
onViewRules(id);
break;
case 'create-rule':
onCreateRule(id);
break;
default:
throw new Error(`Unrecognized menu option: ${String(name)}`);
}
setMenuOpen(false);
}}
/>
</Popover>
<SelectCell
exposed={
payee.transfer_acct == null && (hovered || selected || editing)
@@ -42,10 +42,8 @@ import {
import { useDispatch } from '#redux';
import {
useAddDashboardWidgetMutation,
useCopyDashboardWidgetMutation,
useDeleteDashboardPageMutation,
useImportDashboardPageMutation,
useRemoveDashboardWidgetMutation,
useResetDashboardPageMutation,
useUpdateDashboardWidgetMutation,
useUpdateDashboardWidgetsMutation,
@@ -306,12 +304,6 @@ export function Overview({ dashboard }: OverviewProps) {
});
};
const removeDashboardWidgetMutation = useRemoveDashboardWidgetMutation();
const onRemoveWidget = (widgetId: string) => {
removeDashboardWidgetMutation.mutate({ id: widgetId });
};
const onExport = () => {
const data = {
version: 1,
@@ -456,15 +448,6 @@ export function Overview({ dashboard }: OverviewProps) {
});
};
const copyDashboardWidgetMutation = useCopyDashboardWidgetMutation();
const onCopyWidget = (widgetId: string, targetDashboardId: string) => {
copyDashboardWidgetMutation.mutate({
id: widgetId,
targetDashboardPageId: targetDashboardId,
});
};
const deleteDashboardPageMutation = useDeleteDashboardPageMutation();
const onDeleteDashboard = async (id: string) => {
@@ -806,8 +789,8 @@ export function Overview({ dashboard }: OverviewProps) {
<ErrorBoundary
fallbackRender={() => (
<MissingReportCard
widgetId={item.i}
isEditing={isEditing}
onRemove={() => onRemoveWidget(item.i)}
>
<Trans>This widget has failed to load.</Trans>
</MissingReportCard>
@@ -822,10 +805,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'crossover-card' ? (
<CrossoverCard
@@ -836,10 +815,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'age-of-money-card' &&
ageOfMoneyReportEnabled ? (
@@ -850,10 +825,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'cash-flow-card' ? (
<CashFlowCard
@@ -863,10 +834,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'spending-card' ? (
<SpendingCard
@@ -876,10 +843,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'budget-analysis-card' &&
budgetAnalysisReportEnabled ? (
@@ -890,10 +853,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'balance-forecast-card' &&
balanceForecastReportEnabled ? (
@@ -905,31 +864,21 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'markdown-card' ? (
<MarkdownCard
widgetId={item.i}
isEditing={isEditing}
meta={widget.meta}
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'custom-report' ? (
<CustomReportListCards
widgetId={item.i}
isEditing={isEditing}
report={customReportMap.get(widget.meta.id)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'summary-card' ? (
<SummaryCard
@@ -939,10 +888,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'calendar-card' ? (
<CalendarCard
@@ -953,10 +898,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'formula-card' && formulaMode ? (
<FormulaCard
@@ -966,10 +907,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'sankey-card' &&
sankeyFeatureFlag ? (
@@ -980,10 +917,6 @@ export function Overview({ dashboard }: OverviewProps) {
onMetaChange={newMeta =>
onMetaChange(item, newMeta)
}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : null}
</ErrorBoundary>
@@ -1,41 +1,47 @@
import React, { useEffect, useRef, useState } from 'react';
import type { ComponentProps, CSSProperties, ReactNode } from 'react';
import type { CSSProperties, ReactNode, RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { useContextMenu } from '#hooks/useContextMenu';
import { useIsInViewport } from '#hooks/useIsInViewport';
import { useNavigate } from '#hooks/useNavigate';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
import {
useCopyDashboardWidgetMutation,
useRemoveDashboardWidgetMutation,
} from '#reports/mutations';
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
type ReportCardProps = {
widgetId: string;
isEditing?: boolean;
disableClick?: boolean;
to?: string;
children: ReactNode;
menuItems?: ComponentProps<typeof Menu<string>>['items'];
onMenuSelect?: ComponentProps<typeof Menu<string>>['onMenuSelect'];
size?: number;
style?: CSSProperties;
onRename?: () => void;
contextMenuTriggerRef?: RefObject<HTMLDivElement | null>;
};
export function ReportCard({
widgetId,
isEditing,
disableClick,
to,
menuItems,
onMenuSelect,
children,
size = 1,
style,
onRename,
contextMenuTriggerRef,
}: ReportCardProps) {
const ref = useRef(null);
const isInViewport = useIsInViewport(ref);
@@ -54,8 +60,9 @@ export function ReportCard({
const layoutProps = {
isEditing,
menuItems,
onMenuSelect,
widgetId,
onRename,
contextMenuTriggerRef,
};
const content = (
@@ -124,27 +131,71 @@ export function ReportCard({
type LayoutProps = {
children: ReactNode;
} & Pick<ReportCardProps, 'isEditing' | 'menuItems' | 'onMenuSelect'>;
} & Pick<
ReportCardProps,
'isEditing' | 'widgetId' | 'onRename' | 'contextMenuTriggerRef'
>;
function Layout({ children, isEditing, menuItems, onMenuSelect }: LayoutProps) {
function Layout({
children,
isEditing,
widgetId,
onRename,
contextMenuTriggerRef,
}: LayoutProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const triggerRef = useRef(null);
const viewRef = useRef(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const internalViewRef = useRef<HTMLDivElement>(null);
const viewRef = contextMenuTriggerRef || internalViewRef;
const {
setMenuOpen,
menuOpen,
handleContextMenu,
resetPosition,
position,
asContextMenu,
} = useContextMenu();
const removeDashboardWidgetMutation = useRemoveDashboardWidgetMutation();
const copyDashboardWidgetMutation = useCopyDashboardWidgetMutation();
useContextMenu({
triggerRef: viewRef,
items: [
onRename && {
name: 'rename',
text: t('Rename'),
onClick: onRename,
order: 1,
},
{
name: 'remove',
text: t('Remove'),
onClick: () => removeDashboardWidgetMutation.mutate({ id: widgetId }),
order: 1,
},
{
name: 'copy',
text: t('Copy to dashboard'),
onClick: () => {
dispatch(
pushModal({
modal: {
name: 'copy-widget-to-dashboard',
options: {
onSelect: targetDashboardId => {
copyDashboardWidgetMutation.mutate({
id: widgetId,
targetDashboardPageId: targetDashboardId,
});
},
},
},
}),
);
},
order: 1,
},
],
});
return (
<View
ref={viewRef}
onContextMenu={handleContextMenu}
style={{
display: 'block',
height: '100%',
@@ -157,54 +208,42 @@ function Layout({ children, isEditing, menuItems, onMenuSelect }: LayoutProps) {
},
}}
>
{menuItems && (
<>
{isEditing && (
<View
className={[
menuOpen ? undefined : 'hover-visible',
NON_DRAGGABLE_AREA_CLASS_NAME,
].join(' ')}
style={{
position: 'absolute',
top: 7,
right: 3,
zIndex: 1,
}}
>
<Button
ref={triggerRef}
variant="bare"
aria-label={t('Menu')}
onPress={() => {
resetPosition();
setMenuOpen(true);
}}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
</View>
)}
<Popover
triggerRef={asContextMenu ? viewRef : triggerRef}
isOpen={Boolean(menuOpen)}
onOpenChange={() => setMenuOpen(false)}
isNonModal
placement={asContextMenu ? 'bottom start' : 'bottom end'}
{...position}
{isEditing && (
<View
className={['hover-visible', NON_DRAGGABLE_AREA_CLASS_NAME].join(' ')}
style={{
position: 'absolute',
top: 7,
right: 3,
zIndex: 1,
}}
>
<Button
ref={triggerRef}
variant="bare"
aria-label={t('Menu')}
onPress={() => {
if (viewRef.current) {
const rect = triggerRef.current?.getBoundingClientRect();
const clientX = rect ? rect.left : 0;
const clientY = rect ? rect.bottom : 0;
viewRef.current.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX,
clientY,
}),
);
}
}}
>
<Menu
className={NON_DRAGGABLE_AREA_CLASS_NAME}
onMenuSelect={onMenuSelect}
items={menuItems}
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Popover>
</>
</Button>
</View>
)}
{children}
@@ -18,7 +18,6 @@ import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { calculateTimeRange } from '#components/reports/reportRanges';
import { createAgeOfMoneySpreadsheet } from '#components/reports/spreadsheets/age-of-money-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
// Determine status color based on age
@@ -46,8 +45,6 @@ type AgeOfMoneyCardProps = {
isEditing?: boolean;
meta?: AgeOfMoneyWidget['meta'];
onMetaChange: (newMeta: AgeOfMoneyWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function AgeOfMoneyCard({
@@ -55,8 +52,6 @@ export function AgeOfMoneyCard({
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: AgeOfMoneyCardProps) {
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
@@ -65,9 +60,6 @@ export function AgeOfMoneyCard({
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const [isCardHovered, setIsCardHovered] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
useEffect(() => {
async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction');
@@ -102,27 +94,11 @@ export function AgeOfMoneyCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/age-of-money/${widgetId}`}
menuItems={[
{ name: 'rename', text: t('Rename') },
{ name: 'remove', text: t('Remove') },
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
style={{ flex: 1 }}
@@ -26,7 +26,6 @@ import { LoadingIndicator } from '#components/reports/LoadingIndicator';
import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { calculateTimeRange } from '#components/reports/reportRanges';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useBalanceForecast } from '#hooks/useBalanceForecast';
import { useFormat } from '#hooks/useFormat';
import { useSyncedPref } from '#hooks/useSyncedPref';
@@ -44,8 +43,6 @@ type BalanceForecastCardProps = {
accounts: AccountEntity[];
meta?: BalanceForecastWidget['meta'];
onMetaChange: (newMeta: BalanceForecastWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function BalanceForecastCard({
@@ -54,8 +51,6 @@ export function BalanceForecastCard({
accounts,
meta,
onMetaChange,
onRemove,
onCopy,
}: BalanceForecastCardProps) {
const { t } = useTranslation();
const format = useFormat();
@@ -67,9 +62,6 @@ export function BalanceForecastCard({
: 'schedules';
const isTrackingBudgetForecast = source === 'tracking-budget';
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const [isCardHovered, setIsCardHovered] = useState(false);
@@ -153,33 +145,11 @@ export function BalanceForecastCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/forecast/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
style={{ flex: 1 }}
@@ -17,7 +17,6 @@ import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { calculateTimeRange } from '#components/reports/reportRanges';
import { createBudgetAnalysisSpreadsheet } from '#components/reports/spreadsheets/budget-analysis-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useFormat } from '#hooks/useFormat';
@@ -26,8 +25,6 @@ type BudgetAnalysisCardProps = {
isEditing?: boolean;
meta?: BudgetAnalysisWidget['meta'];
onMetaChange: (newMeta: BudgetAnalysisWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function BudgetAnalysisCard({
@@ -35,8 +32,6 @@ export function BudgetAnalysisCard({
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: BudgetAnalysisCardProps) {
const { t } = useTranslation();
const format = useFormat();
@@ -44,9 +39,6 @@ export function BudgetAnalysisCard({
const [isCardHovered, setIsCardHovered] = useState(false);
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const timeFrame = meta?.timeFrame ?? {
start: monthUtils.subMonths(monthUtils.currentMonth(), 5),
end: monthUtils.currentMonth(),
@@ -84,33 +76,11 @@ export function BudgetAnalysisCard({
const balance = latestInterval?.balance ?? 0;
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/budget-analysis/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
style={{ flex: 1 }}
@@ -36,7 +36,6 @@ import { ReportCardName } from '#components/reports/ReportCardName';
import { calculateTimeRange } from '#components/reports/reportRanges';
import { calendarSpreadsheet } from '#components/reports/spreadsheets/calendar-spreadsheet';
import type { CalendarDataType } from '#components/reports/spreadsheets/calendar-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useFormat } from '#hooks/useFormat';
import type { FormatType } from '#hooks/useFormat';
@@ -49,8 +48,6 @@ type CalendarCardProps = {
isEditing?: boolean;
meta?: CalendarWidget['meta'];
onMetaChange: (newMeta: CalendarWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
};
@@ -59,8 +56,6 @@ export function CalendarCard({
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
firstDayOfWeekIdx,
}: CalendarCardProps) {
const { t } = useTranslation();
@@ -167,38 +162,13 @@ export function CalendarCard({
return data?.calendarData.length;
}, [data]);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/calendar/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
ref={el => (el ? cardRef(el) : undefined)}
@@ -20,7 +20,6 @@ import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { calculateTimeRange } from '#components/reports/reportRanges';
import { simpleCashFlow } from '#components/reports/spreadsheets/cash-flow-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useFormat } from '#hooks/useFormat';
@@ -97,8 +96,6 @@ type CashFlowCardProps = {
isEditing?: boolean;
meta?: CashFlowWidget['meta'];
onMetaChange: (newMeta: CashFlowWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function CashFlowCard({
@@ -106,17 +103,12 @@ export function CashFlowCard({
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: CashFlowCardProps) {
const { t } = useTranslation();
const animationProps = useRechartsAnimation();
const [latestTransaction, setLatestTransaction] = useState<string>('');
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
useEffect(() => {
async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction');
@@ -149,33 +141,11 @@ export function CashFlowCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/cash-flow/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
style={{ flex: 1 }}
@@ -22,7 +22,6 @@ import { calculateTimeRange } from '#components/reports/reportRanges';
import { defaultTimeFrame } from '#components/reports/reports/Crossover';
import { createCrossoverSpreadsheet } from '#components/reports/spreadsheets/crossover-spreadsheet';
import type { CrossoverData } from '#components/reports/spreadsheets/crossover-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useCategories } from '#hooks/useCategories';
import { useFormat } from '#hooks/useFormat';
@@ -34,8 +33,6 @@ type CrossoverCardProps = {
accounts: AccountEntity[];
meta?: CrossoverWidget['meta'];
onMetaChange: (newMeta: CrossoverWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function CrossoverCard({
@@ -44,8 +41,6 @@ export function CrossoverCard({
accounts,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: CrossoverCardProps) {
const locale = useLocale();
const { t } = useTranslation();
@@ -54,9 +49,6 @@ export function CrossoverCard({
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
// Calculate date range from meta or use default range
const [start, setStart] = useState<string>('');
const [end, setEnd] = useState<string>('');
@@ -196,27 +188,11 @@ export function CrossoverCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/crossover/${widgetId}`}
menuItems={[
{ name: 'rename', text: t('Rename') },
{ name: 'remove', text: t('Remove') },
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
style={{ flex: 1 }}
@@ -14,7 +14,6 @@ import type { CustomReportEntity } from '@actual-app/core/types/models';
import { DateRange } from '#components/reports/DateRange';
import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { calculateHasWarning } from '#components/reports/util';
import { useAccounts } from '#hooks/useAccounts';
import { useCategories } from '#hooks/useCategories';
@@ -28,23 +27,21 @@ import { GetCardData } from './GetCardData';
import { MissingReportCard } from './MissingReportCard';
type CustomReportListCardsProps = {
widgetId: string;
isEditing?: boolean;
report?: CustomReportEntity;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function CustomReportListCards({
widgetId,
isEditing,
report,
onRemove,
onCopy,
}: CustomReportListCardsProps) {
// It's possible for a dashboard to reference a non-existing
// custom report
if (!report) {
return (
<MissingReportCard isEditing={isEditing} onRemove={onRemove}>
<MissingReportCard widgetId={widgetId} isEditing={isEditing}>
<Trans>This custom report has been deleted.</Trans>
</MissingReportCard>
);
@@ -52,20 +49,18 @@ export function CustomReportListCards({
return (
<CustomReportListCardsInner
widgetId={widgetId}
isEditing={isEditing}
report={report}
onRemove={onRemove}
onCopy={onCopy}
/>
);
}
function CustomReportListCardsInner({
widgetId,
isEditing,
report,
onRemove,
onCopy,
}: Omit<CustomReportListCardsProps, 'report'> & {
}: CustomReportListCardsProps & {
report: CustomReportEntity;
}) {
const { t } = useTranslation();
@@ -76,9 +71,6 @@ function CustomReportListCardsInner({
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const { data: payees = [] } = usePayees();
const { data: accounts = [] } = useAccounts();
const { data: categories = { list: [], grouped: [] } } = useCategories();
@@ -139,33 +131,11 @@ function CustomReportListCardsInner({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/custom/${report.id}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'remove':
onRemove();
break;
case 'rename':
setNameMenuOpen(true);
break;
default:
throw new Error(`Unrecognized menu option: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View style={{ flex: 1, padding: 10 }}>
<View
@@ -7,7 +7,6 @@ import type { FormulaWidget } from '@actual-app/core/types/models';
import { FormulaResult } from '#components/reports/FormulaResult';
import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useFormulaExecution } from '#hooks/useFormulaExecution';
import { useThemeColors } from '#hooks/useThemeColors';
@@ -16,8 +15,6 @@ type FormulaCardProps = {
isEditing?: boolean;
meta?: FormulaWidget['meta'];
onMetaChange: (newMeta: FormulaWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function FormulaCard({
@@ -25,13 +22,9 @@ export function FormulaCard({
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: FormulaCardProps) {
const { t } = useTranslation();
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const themeColors = useThemeColors();
const containerRef = useRef<HTMLDivElement>(null);
@@ -74,37 +67,11 @@ export function FormulaCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/formula/${widgetId}`}
menuItems={[
...(showTitle
? [
{
name: 'rename',
text: t('Rename'),
},
]
: []),
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
break;
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View style={{ flex: 1, overflow: 'hidden' }}>
{showTitle && (
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { TextArea } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
@@ -14,7 +14,7 @@ import remarkGfm from 'remark-gfm';
import { NON_DRAGGABLE_AREA_CLASS_NAME } from '#components/reports/constants';
import { ReportCard } from '#components/reports/ReportCard';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useContextMenu } from '#hooks/useContextMenu';
import {
markdownBaseStyles,
remarkBreaks,
@@ -34,91 +34,75 @@ const markdownStyles = css(markdownBaseStyles, {
});
type MarkdownCardProps = {
widgetId: string;
isEditing?: boolean;
meta: MarkdownWidget['meta'];
onMetaChange: (newMeta: MarkdownWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function MarkdownCard({
widgetId,
isEditing,
meta,
onMetaChange,
onRemove,
onCopy,
}: MarkdownCardProps) {
const { t } = useTranslation();
const [isVisibleTextArea, setIsVisibleTextArea] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const contextMenuTriggerRef = useRef(null);
useContextMenu({
triggerRef: contextMenuTriggerRef,
enabled: !isVisibleTextArea,
items: [
{
type: Menu.label,
name: t('Text position:'),
text: '',
},
{
name: 'text-left',
text: t('Left'),
onClick: () =>
onMetaChange({
...meta,
text_align: 'left',
}),
},
{
name: 'text-center',
text: t('Center'),
onClick: () =>
onMetaChange({
...meta,
text_align: 'center',
}),
},
{
name: 'text-right',
text: t('Right'),
onClick: () =>
onMetaChange({
...meta,
text_align: 'right',
}),
},
Menu.line,
{
name: 'edit',
text: t('Edit content'),
onClick: () => setIsVisibleTextArea(true),
},
],
});
return (
<ReportCard
isEditing={isEditing}
disableClick={isVisibleTextArea}
menuItems={[
{
type: Menu.label,
name: t('Text position:'),
text: '',
},
{
name: 'text-left',
text: t('Left'),
},
{
name: 'text-center',
text: t('Center'),
},
{
name: 'text-right',
text: t('Right'),
},
Menu.line,
{
name: 'edit',
text: t('Edit content'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'text-left':
onMetaChange({
...meta,
text_align: 'left',
});
break;
case 'text-center':
onMetaChange({
...meta,
text_align: 'center',
});
break;
case 'text-right':
onMetaChange({
...meta,
text_align: 'right',
});
break;
case 'edit':
setIsVisibleTextArea(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
contextMenuTriggerRef={contextMenuTriggerRef}
widgetId={widgetId}
>
<View
style={{
@@ -1,43 +1,23 @@
import React from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { View } from '@actual-app/components/view';
import { ReportCard } from '#components/reports/ReportCard';
type MissingReportCardProps = {
widgetId: string;
isEditing?: boolean;
onRemove: () => void;
children: ReactNode;
};
export function MissingReportCard({
widgetId,
isEditing,
onRemove,
children,
}: MissingReportCardProps) {
const { t } = useTranslation();
return (
<ReportCard
isEditing={isEditing}
menuItems={[
{
name: 'remove',
text: t('Remove'),
},
]}
onMenuSelect={item => {
switch (item) {
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized menu option: ${item}`);
}
}}
>
<ReportCard widgetId={widgetId} isEditing={isEditing}>
<View
style={{
flex: 1,
@@ -22,7 +22,6 @@ import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { calculateTimeRange } from '#components/reports/reportRanges';
import { createSpreadsheet as netWorthSpreadsheet } from '#components/reports/spreadsheets/net-worth-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
@@ -34,8 +33,6 @@ type NetWorthCardProps = {
accounts: AccountEntity[];
meta?: NetWorthWidget['meta'];
onMetaChange: (newMeta: NetWorthWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function NetWorthCard({
@@ -44,8 +41,6 @@ export function NetWorthCard({
accounts,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: NetWorthCardProps) {
const locale = useLocale();
const { t } = useTranslation();
@@ -58,9 +53,6 @@ export function NetWorthCard({
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const [isCardHovered, setIsCardHovered] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
useEffect(() => {
async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction');
@@ -108,33 +100,11 @@ export function NetWorthCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/net-worth/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
style={{ flex: 1 }}
@@ -27,7 +27,6 @@ import type {
Graph,
GraphLayers,
} from '#components/reports/spreadsheets/sankey-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useCategories } from '#hooks/useCategories';
import { useLocale } from '#hooks/useLocale';
@@ -38,22 +37,16 @@ type SankeyCardProps = {
isEditing?: boolean;
meta?: SankeyWidget['meta'];
onMetaChange: (newMeta: SankeyWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function SankeyCard({
widgetId,
isEditing,
meta,
onMetaChange,
onRemove,
onCopy,
}: SankeyCardProps) {
const { t } = useTranslation();
const locale = useLocale();
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const { data: { grouped: groupedCategories = [] } = { grouped: [] } } =
useCategories();
@@ -179,33 +172,11 @@ export function SankeyCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/sankey/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', padding: 20 }}>
@@ -21,7 +21,6 @@ import {
normalizeSpendingAverageRange,
} from '#components/reports/spendingAverageRange';
import { createSpendingSpreadsheet } from '#components/reports/spreadsheets/spending-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useFormat } from '#hooks/useFormat';
import { useSyncedPref } from '#hooks/useSyncedPref';
@@ -31,8 +30,6 @@ type SpendingCardProps = {
isEditing?: boolean;
meta?: SpendingWidget['meta'];
onMetaChange: (newMeta: SpendingWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function SpendingCard({
@@ -40,8 +37,6 @@ export function SpendingCard({
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: SpendingCardProps) {
const { t } = useTranslation();
const format = useFormat();
@@ -52,9 +47,6 @@ export function SpendingCard({
const [isCardHovered, setIsCardHovered] = useState(false);
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const spendingReportMode = meta?.mode ?? 'single-month';
const averageRange = normalizeSpendingAverageRange(meta?.averageRange);
const averageRangeLabel = getSpendingAverageRangeLabel(averageRange, t);
@@ -97,33 +89,11 @@ export function SpendingCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/spending/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View
style={{ flex: 1 }}
@@ -16,7 +16,6 @@ import { ReportCardValueSkeleton } from '#components/reports/ReportCardValueSkel
import { calculateTimeRange } from '#components/reports/reportRanges';
import { summarySpreadsheet } from '#components/reports/spreadsheets/summary-spreadsheet';
import { SummaryNumber } from '#components/reports/SummaryNumber';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
import { useLocale } from '#hooks/useLocale';
@@ -25,8 +24,6 @@ type SummaryCardProps = {
isEditing?: boolean;
meta?: SummaryWidget['meta'];
onMetaChange: (newMeta: SummaryWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function SummaryCard({
@@ -34,17 +31,12 @@ export function SummaryCard({
isEditing,
meta = {},
onMetaChange,
onRemove,
onCopy,
}: SummaryCardProps) {
const locale = useLocale();
const { t } = useTranslation();
const [latestTransaction, setLatestTransaction] = useState<string>('');
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
useEffect(() => {
async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction');
@@ -97,33 +89,11 @@ export function SummaryCard({
return (
<ReportCard
widgetId={widgetId}
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/summary/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized menu selection: ${item}`);
}
}}
onRename={() => setNameMenuOpen(true)}
>
<View style={{ flex: 1, overflow: 'hidden' }}>
<View style={{ flexGrow: 0, flexShrink: 0, padding: 20 }}>
@@ -4,8 +4,6 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgRightArrow2 } from '@actual-app/components/icons/v0';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
@@ -55,8 +53,23 @@ export const RuleRow = memo(
const { t } = useTranslation();
const triggerRef = useRef(null);
const { setMenuOpen, menuOpen, handleContextMenu, position } =
useContextMenu();
useContextMenu({
triggerRef,
items: [
onEditRule && {
name: 'edit',
text: t('Edit'),
onClick: () => onEditRule(rule),
},
onDeleteRule &&
!hasSchedule && {
name: 'delete',
text: t('Delete'),
onClick: () => onDeleteRule(rule),
},
],
});
return (
<Row
@@ -75,38 +88,7 @@ export const RuleRow = memo(
collapsed
onMouseEnter={() => onHover && onHover(rule.id)}
onMouseLeave={() => onHover && onHover(null)}
onContextMenu={handleContextMenu}
>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
{...position}
style={{ width: 200, margin: 1 }}
isNonModal
>
<Menu
items={[
onEditRule && { name: 'edit', text: t('Edit') },
onDeleteRule &&
!hasSchedule && { name: 'delete', text: t('Delete') },
]}
onMenuSelect={name => {
switch (name) {
case 'delete':
onDeleteRule(rule);
break;
case 'edit':
onEditRule(rule);
break;
default:
throw new Error(`Unrecognized menu option: ${String(name)}`);
}
setMenuOpen(false);
}}
/>
</Popover>
<SelectCell
exposed={hovered || selected}
focused
@@ -6,8 +6,6 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
import { SvgCheck } from '@actual-app/components/icons/v2';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
@@ -15,10 +13,7 @@ import { View } from '@actual-app/components/view';
import { format as monthUtilFormat } from '@actual-app/core/shared/months';
import { getNormalisedString } from '@actual-app/core/shared/normalisation';
import { getScheduledAmount } from '@actual-app/core/shared/schedules';
import type {
ScheduleStatuses,
ScheduleStatusType,
} from '@actual-app/core/shared/schedules';
import type { ScheduleStatuses } from '@actual-app/core/shared/schedules';
import type { ScheduleEntity } from '@actual-app/core/types/models';
import { FinancialText } from '#components/FinancialText';
@@ -68,64 +63,6 @@ export type ScheduleItemAction =
export const ROW_HEIGHT = 43;
function OverflowMenu({
schedule,
status,
onAction,
}: {
schedule: ScheduleEntity;
status: ScheduleStatusType;
onAction: SchedulesTableProps['onAction'];
}) {
const { t } = useTranslation();
const getMenuItems = () => {
const menuItems: { name: ScheduleItemAction; text: string }[] = [];
menuItems.push(
{
name: 'post-transaction',
text: t('Post transaction'),
},
{
name: 'post-transaction-today',
text: t('Post transaction today'),
},
);
if (status === 'completed') {
menuItems.push({
name: 'restart',
text: t('Restart'),
});
} else {
menuItems.push(
{
name: 'skip',
text: t('Skip next scheduled date'),
},
{
name: 'complete',
text: t('Complete'),
},
);
}
menuItems.push({ name: 'delete', text: t('Delete') });
return menuItems;
};
return (
<Menu
onMenuSelect={name => {
onAction(name, schedule.id);
}}
items={getMenuItems()}
/>
);
}
export function ScheduleAmountCell({
amount,
op,
@@ -227,14 +164,43 @@ function ScheduleRow({
const rowRef = useRef(null);
const buttonRef = useRef(null);
const {
setMenuOpen,
menuOpen,
handleContextMenu,
resetPosition,
position,
asContextMenu,
} = useContextMenu();
const status = statuses.get(schedule.id);
useContextMenu({
triggerRef: rowRef,
items: !minimal
? [
{
name: 'post-transaction',
text: t('Post transaction'),
onClick: () => onAction('post-transaction', schedule.id),
},
{
name: 'post-transaction-today',
text: t('Post transaction today'),
onClick: () => onAction('post-transaction-today', schedule.id),
},
{
name: 'restart',
text: t('Restart'),
onClick: () => onAction('restart', schedule.id),
hidden: status !== 'completed',
},
{
name: 'skip',
text: t('Skip next scheduled date'),
onClick: () => onAction('skip', schedule.id),
hidden: status === 'completed',
},
{
name: 'complete',
text: t('Complete'),
onClick: () => onAction('complete', schedule.id),
hidden: status === 'completed',
},
]
: [],
});
return (
<Row
@@ -248,29 +214,7 @@ function ScheduleRow({
color: theme.tableText,
':hover': { backgroundColor: theme.tableRowBackgroundHover },
}}
onContextMenu={handleContextMenu}
>
{!minimal && (
<Popover
triggerRef={asContextMenu ? rowRef : buttonRef}
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
isNonModal
placement="bottom start"
{...position}
style={{ margin: 1 }}
>
<OverflowMenu
schedule={schedule}
status={statuses.get(schedule.id)}
onAction={(action, id) => {
onAction(action, id);
resetPosition();
setMenuOpen(false);
}}
/>
</Popover>
)}
<Field width="flex" name="name">
<Text
style={
@@ -315,8 +259,18 @@ function ScheduleRow({
variant="bare"
aria-label={t('Menu')}
onPress={() => {
resetPosition();
setMenuOpen(true);
if (rowRef.current) {
const rect = buttonRef.current?.getBoundingClientRect();
const clientX = rect ? rect.left : 0;
const clientY = rect ? rect.bottom : 0;
(rowRef.current as HTMLElement).dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX,
clientY,
}),
);
}
}}
>
<SvgDotsHorizontalTriple
@@ -11,8 +11,6 @@ import {
} from '@actual-app/components/icons/v2';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { Input } from '@actual-app/components/input';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
@@ -35,7 +33,7 @@ import { useIsTestEnv } from '#hooks/useIsTestEnv';
import { useNotes } from '#hooks/useNotes';
import { useSyncedPref } from '#hooks/useSyncedPref';
import { openAccountCloseModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
import { useDispatch, useSelector } from '#redux';
import type { Binding, SheetFields } from '#spreadsheet';
export const accountNameStyle: CSSProperties = {
@@ -97,8 +95,6 @@ export function Account<FieldName extends SheetFields<'account'>>({
: 'title';
const triggerRef = useRef(null);
const { setMenuOpen, menuOpen, handleContextMenu, position } =
useContextMenu();
const { dragRef } = useDraggable({
type,
@@ -132,12 +128,38 @@ export function Account<FieldName extends SheetFields<'account'>>({
const balanceCell = <CellValue binding={query} type="financial" />;
const isContextMenuOpen = useSelector(state =>
state.contextMenu.items.some(
i =>
typeof i === 'object' && 'name' in i && i.name.startsWith('account-'),
),
);
useContextMenu({
triggerRef,
enabled: account && needsTooltip,
items: [
{
name: 'account-rename',
text: t('Rename'),
onClick: () => setIsEditing(true),
},
account?.closed
? {
name: 'account-reopen',
text: t('Reopen'),
onClick: () => reopenAccount.mutate({ id: account.id }),
}
: {
name: 'account-close',
text: t('Close'),
onClick: () =>
dispatch(openAccountCloseModal({ accountId: account.id })),
},
],
});
const accountRow = (
<View
innerRef={dropRef}
style={{ flexShrink: 0, ...outerStyle }}
onContextMenu={needsTooltip ? handleContextMenu : undefined}
>
<View innerRef={dropRef} style={{ flexShrink: 0, ...outerStyle }}>
<View innerRef={triggerRef}>
<DropHighlight pos={dropPos} />
<View innerRef={handleDragRef}>
@@ -246,50 +268,6 @@ export function Account<FieldName extends SheetFields<'account'>>({
}
/>
</Link>
{account && (
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
style={{ width: 200, margin: 1 }}
isNonModal
{...position}
>
<Menu
onMenuSelect={type => {
switch (type) {
case 'close': {
void dispatch(
openAccountCloseModal({ accountId: account.id }),
);
break;
}
case 'reopen': {
reopenAccount.mutate({ id: account.id });
break;
}
case 'rename': {
setIsEditing(true);
break;
}
default: {
throw new Error(
`Unrecognized menu option: ${String(type)}`,
);
}
}
setMenuOpen(false);
}}
items={[
{ name: 'rename', text: t('Rename') },
account.closed
? { name: 'reopen', text: t('Reopen') }
: { name: 'close', text: t('Close') },
]}
/>
</Popover>
)}
</View>
</View>
</View>
@@ -370,7 +348,7 @@ export function Account<FieldName extends SheetFields<'account'>>({
triggerProps={{
delay: 1000,
closeDelay: 250,
isDisabled: menuOpen,
isDisabled: isContextMenuOpen,
}}
>
{accountRow}
@@ -6,8 +6,6 @@ import { Button } from '@actual-app/components/button';
import { SvgExpandArrow } from '@actual-app/components/icons/v0';
import { InitialFocus } from '@actual-app/components/initial-focus';
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';
@@ -60,43 +58,34 @@ function EditableBudgetName() {
const [budgetName, setBudgetNamePref] = useMetadataPref('budgetName');
const dispatch = useDispatch();
const navigate = useNavigate();
const triggerRef = useRef<HTMLButtonElement>(null);
const [editing, setEditing] = useState(false);
const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } =
useContextMenu();
function onMenuSelect(type: string) {
setMenuOpen(false);
switch (type) {
case 'rename':
setEditing(true);
break;
case 'settings':
void navigate('/settings');
break;
case 'loadBackup':
if (isElectron()) {
dispatch(
pushModal({
modal: { name: 'load-backup', options: {} },
}),
);
}
break;
case 'close':
void dispatch(closeBudget());
break;
default:
}
}
const items = [
{ name: 'rename', text: t('Rename budget') },
{ name: 'settings', text: t('Settings') },
isElectron() ? { name: 'loadBackup', text: t('Load Backup…') } : null,
{ name: 'close', text: t('Switch file') },
].filter(item => item !== null);
const triggerRef = useRef<HTMLButtonElement>(null);
const { handleContextMenu } = useContextMenu({
triggerRef,
items: [
{
name: 'rename',
text: t('Rename budget'),
onClick: () => setEditing(true),
},
{
name: 'settings',
text: t('Settings'),
onClick: () => void navigate('/settings'),
},
isElectron() && {
name: 'loadBackup',
text: t('Load Backup…'),
onClick: () =>
dispatch(pushModal({ modal: { name: 'load-backup', options: {} } })),
},
{
name: 'close',
text: t('Switch file'),
onClick: () => void dispatch(closeBudget()),
},
],
});
if (editing) {
return (
@@ -121,42 +110,27 @@ function EditableBudgetName() {
}
return (
<View onContextMenu={handleContextMenu}>
<Button
ref={triggerRef}
variant="bare"
style={{
color: theme.sidebarBudgetName,
fontSize: 16,
fontWeight: 500,
marginLeft: -5,
flex: '0 auto',
}}
onPress={() => {
resetPosition();
setMenuOpen(true);
}}
>
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
{budgetName || t('Unnamed')}
</Text>
<SvgExpandArrow
width={7}
height={7}
style={{ flexShrink: 0, marginLeft: 5 }}
/>
</Button>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
style={{ margin: 1 }}
{...position}
>
<Menu onMenuSelect={onMenuSelect} items={items} />
</Popover>
</View>
<Button
ref={triggerRef}
data-testid="budget-name"
variant="bare"
style={{
color: theme.sidebarBudgetName,
fontSize: 16,
fontWeight: 500,
marginLeft: -5,
flex: '0 auto',
}}
onClick={handleContextMenu}
>
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
{budgetName || t('Unnamed')}
</Text>
<SvgExpandArrow
width={7}
height={7}
style={{ flexShrink: 0, marginLeft: 5 }}
/>
</Button>
);
}
@@ -37,6 +37,7 @@ import { View } from '@actual-app/components/view';
import { useFormat } from '#hooks/useFormat';
import type { FormatType } from '#hooks/useFormat';
import { useMergedRefs } from '#hooks/useMergedRefs';
import { useModalState } from '#hooks/useModalState';
import {
AvoidRefocusScrollProvider,
@@ -267,11 +268,13 @@ export function Cell({
],
);
const mergedRef = useMergedRefs(viewRef, viewProps.ref, viewProps.innerRef);
return (
<View
innerRef={viewRef}
style={{ ...widthStyle, ...cellStyle, ...style }}
{...viewProps}
innerRef={mergedRef}
data-testid={name}
>
{conditionalPrivacyFilter}
@@ -84,7 +84,6 @@ export function ManageTags() {
{tags.length ? (
<TagsList
tags={filteredTags}
selectedItems={selectedInst.items}
hoveredTag={hoveredTag}
onHover={id => setHoveredTag(id ?? undefined)}
/>
@@ -2,8 +2,6 @@ import React, { memo, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { SvgArrowThinRight } from '@actual-app/components/icons/v1';
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 type { TagEntity } from '@actual-app/core/types/models';
@@ -18,24 +16,30 @@ import {
import { useContextMenu } from '#hooks/useContextMenu';
import { useNavigate } from '#hooks/useNavigate';
import { useProperFocus } from '#hooks/useProperFocus';
import { useSelectedDispatch } from '#hooks/useSelected';
import { useDeleteTagMutation, useUpdateTagMutation } from '#tags';
import { useSelectedDispatch, useSelectedItems } from '#hooks/useSelected';
import {
useDeleteTagsMutation,
useHideTagsMutation,
useUnhideTagsMutation,
useUpdateTagMutation,
} from '#tags';
import { TagEditor } from './TagEditor';
type TagRowProps = {
tag: TagEntity;
hovered?: boolean;
selected?: boolean;
onHover: (id?: string) => void;
focusedField: string | null;
onEdit: (id: string, field: string) => void;
};
export const TagRow = memo(
({ tag, hovered, selected, onHover, focusedField, onEdit }: TagRowProps) => {
({ tag, hovered, onHover, focusedField, onEdit }: TagRowProps) => {
const { t } = useTranslation();
const dispatchSelected = useSelectedDispatch();
const selectedIds = useSelectedItems();
const selected = selectedIds.has(tag.id);
const borderColor = selected ? theme.tableBorderSelected : 'none';
const colorButtonRef = useRef(null);
@@ -44,11 +48,11 @@ export const TagRow = memo(
useProperFocus(resetButtonRef, focusedField === 'select');
const triggerRef = useRef(null);
const { setMenuOpen, menuOpen, handleContextMenu, position } =
useContextMenu();
const navigate = useNavigate();
const { mutate: updateTag } = useUpdateTagMutation();
const { mutate: deleteTag } = useDeleteTagMutation();
const { mutate: deleteTags } = useDeleteTagsMutation();
const { mutate: hideTags } = useHideTagsMutation();
const { mutate: unhideTags } = useUnhideTagsMutation();
const onUpdate = (description: string) => {
updateTag({ tag: { ...tag, description } });
@@ -71,6 +75,30 @@ export const TagRow = memo(
});
};
const contextActionIds = selected ? Array.from(selectedIds) : [tag.id];
useContextMenu({
triggerRef,
items: [
{
name: 'delete',
text: t('Delete'),
onClick: () => deleteTags({ ids: contextActionIds }),
},
tag.hidden
? {
name: 'unhide',
text: t('Unhide'),
onClick: () => unhideTags({ ids: contextActionIds }),
}
: {
name: 'hide',
text: t('Hide'),
onClick: () => hideTags({ ids: contextActionIds }),
},
],
});
return (
<Row
ref={triggerRef}
@@ -86,37 +114,7 @@ export const TagRow = memo(
collapsed
onMouseEnter={() => onHover(tag.id)}
onMouseLeave={() => onHover()}
onContextMenu={handleContextMenu}
>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
{...position}
style={{ width: 200, margin: 1 }}
isNonModal
>
<Menu
items={[
{
name: 'delete',
text: t('Delete'),
},
]}
onMenuSelect={name => {
switch (name) {
case 'delete':
deleteTag({ id: tag.id });
break;
default:
throw new Error(`Unrecognized menu option: ${String(name)}`);
}
setMenuOpen(false);
}}
/>
</Popover>
<SelectCell
exposed={hovered || selected || focusedField === 'select'}
focused={focusedField === 'select'}
@@ -9,17 +9,11 @@ import { TagRow } from './TagRow';
type TagsListProps = {
tags: TagEntity[];
selectedItems: Set<string>;
hoveredTag?: string;
onHover: (id?: string) => void;
};
export function TagsList({
tags,
selectedItems,
hoveredTag,
onHover,
}: TagsListProps) {
export function TagsList({ tags, hoveredTag, onHover }: TagsListProps) {
const tableNavigator = useTableNavigator(tags, [
'select',
'color',
@@ -33,14 +27,12 @@ export function TagsList({
backgroundColor={theme.tableBackground}
renderItem={({ item: tag, focusedField, onEdit }) => {
const hovered = hoveredTag === tag.id;
const selected = selectedItems.has(tag.id);
return (
<TagRow
key={tag.id}
tag={tag}
hovered={hovered}
selected={selected}
onHover={onHover}
focusedField={focusedField}
onEdit={onEdit}
@@ -107,7 +107,6 @@ import {
SchedulesProvider,
useCachedSchedules,
} from '#hooks/useCachedSchedules';
import { useContextMenu } from '#hooks/useContextMenu';
import { DisplayPayeeProvider, useDisplayPayee } from '#hooks/useDisplayPayee';
import {
DropHighlight,
@@ -148,7 +147,7 @@ import type {
TransactionEditFunction,
TransactionUpdateFunction,
} from './table/utils';
import { TransactionMenu } from './TransactionMenu';
import { useTransactionRowContextActions } from './useTransactionRowContextActions';
type TransactionHeaderProps = {
hasSelected: boolean;
@@ -1215,9 +1214,6 @@ const Transaction = memo(function Transaction({
return () => clearTimeout(id);
}, [splitError, allTransactions]);
const { setMenuOpen, menuOpen, handleContextMenu, position } =
useContextMenu();
// Drag and drop support
const isChildTransaction = transaction.is_child;
const parentId = transaction.parent_id;
@@ -1323,6 +1319,19 @@ const Transaction = memo(function Transaction({
dropPos && isValidDropTarget && !isBeingDragged,
);
useTransactionRowContextActions({
rowRef: triggerRef,
transaction,
getTransaction: id => allTransactions?.find(t => t.id === id),
onDelete: ids => onBatchDelete?.(ids),
onDuplicate: ids => onBatchDuplicate?.(ids),
onLinkSchedule: ids => onBatchLinkSchedule?.(ids),
onUnlinkSchedule: ids => onBatchUnlinkSchedule?.(ids),
onCreateRule: ids => onCreateRule?.(ids),
onScheduleAction: (name, ids) => onScheduleAction?.(name, ids),
onMakeAsNonSplitTransactions: ids => onMakeAsNonSplitTransactions?.(ids),
});
return (
<View
innerRef={dropRef}
@@ -1363,35 +1372,7 @@ const Transaction = memo(function Transaction({
...(_unmatched && { opacity: 0.5 }),
...(isBeingDragged && { opacity: 0.5 }),
}}
onContextMenu={handleContextMenu}
>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={menuOpen}
onOpenChange={isOpen => {
if (!isOpen) setMenuOpen(false);
}}
{...position}
style={{ width: 200, margin: 1 }}
isNonModal={false}
>
<TransactionMenu
transaction={transaction}
getTransaction={id => allTransactions?.find(t => t.id === id)}
onDelete={ids => onBatchDelete?.(ids)}
onDuplicate={ids => onBatchDuplicate?.(ids)}
onLinkSchedule={ids => onBatchLinkSchedule?.(ids)}
onUnlinkSchedule={ids => onBatchUnlinkSchedule?.(ids)}
onCreateRule={ids => onCreateRule?.(ids)}
onScheduleAction={(name, ids) => onScheduleAction?.(name, ids)}
onMakeAsNonSplitTransactions={ids =>
onMakeAsNonSplitTransactions?.(ids)
}
closeMenu={() => setMenuOpen(false)}
/>
</Popover>
{splitError && listContainerRef?.current && (
<Popover
triggerRef={triggerRef}
@@ -1987,6 +1968,7 @@ function NotesCell({
onClickTag,
onExpose,
}: NotesCellProps) {
const cellRef = useRef<HTMLDivElement | null>(null);
const [inputValue, setInputValue] = useState(note);
useEffect(() => {
setInputValue(note);
@@ -2004,6 +1986,7 @@ function NotesCell({
return (
<CustomCell
innerRef={cellRef}
width="flex"
name="notes"
value={displayedNote}
@@ -1,8 +1,7 @@
import React, { useMemo } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import { useMemo } from 'react';
import type { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu } from '@actual-app/components/menu';
import { q } from '@actual-app/core/shared/query';
import {
extractScheduleConds,
@@ -11,15 +10,15 @@ import {
import { isPreviewId } from '@actual-app/core/shared/transactions';
import type { TransactionEntity } from '@actual-app/core/types/models';
import type { ContextMenuItem } from '#contextmenu/types';
import { useContextMenu } from '#hooks/useContextMenu';
import { useSchedules } from '#hooks/useSchedules';
import { useSelectedItems } from '#hooks/useSelected';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
type BalanceMenuProps = Omit<
ComponentPropsWithoutRef<typeof Menu>,
'onMenuSelect' | 'items'
> & {
type TransactionRowContextMenuProps = {
rowRef: RefObject<HTMLElement | null>;
transaction: TransactionEntity;
getTransaction: (id: string) => TransactionEntity | undefined;
onDuplicate: (ids: string[]) => void;
@@ -32,10 +31,10 @@ type BalanceMenuProps = Omit<
ids: TransactionEntity['id'][],
) => void;
onMakeAsNonSplitTransactions: (ids: string[]) => void;
closeMenu: () => void;
};
export function TransactionMenu({
export function useTransactionRowContextActions({
rowRef,
transaction,
getTransaction,
onDuplicate,
@@ -45,9 +44,7 @@ export function TransactionMenu({
onCreateRule,
onScheduleAction,
onMakeAsNonSplitTransactions,
closeMenu,
...props
}: BalanceMenuProps) {
}: TransactionRowContextMenuProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const selectedItems = useSelectedItems();
@@ -154,95 +151,85 @@ export function TransactionMenu({
}
}
return (
<Menu
{...props}
onMenuSelect={name => {
switch (name) {
case 'duplicate':
onDuplicate(selectedIds);
break;
case 'delete':
onDelete(selectedIds);
break;
case 'unsplit-transactions':
onMakeAsNonSplitTransactions(selectedIds);
break;
case 'post-transaction':
case 'post-transaction-today':
case 'skip':
case 'complete':
onScheduleAction(name, selectedIds);
break;
case 'view-schedule':
onViewSchedule();
break;
case 'link-schedule':
onLinkSchedule(selectedIds);
break;
case 'unlink-schedule':
onUnlinkSchedule(selectedIds);
break;
case 'create-rule':
onCreateRule(selectedIds);
break;
default:
throw new Error(`Unrecognized menu option: ${name}`);
}
closeMenu();
}}
items={[
...(!types.trans
? [
...(selectedIds.length === 1
? [{ name: 'view-schedule', text: t('View schedule') }]
: []),
{ name: 'post-transaction', text: t('Post transaction') },
{
name: 'post-transaction-today',
text: t('Post transaction today'),
},
...(canBeSkipped
? [{ name: 'skip', text: t('Skip next scheduled date') }]
: []),
...(canBeCompleted
? [{ name: 'complete', text: t('Mark as completed') }]
: []),
]
: [
...(ambiguousDuplication
? []
: [{ name: 'duplicate', text: t('Duplicate') }]),
{ name: 'delete', text: t('Delete') },
...(linked
? [
...(selectedIds.length === 1
? [{ name: 'view-schedule', text: t('View schedule') }]
: []),
{ name: 'unlink-schedule', text: t('Unlink schedule') },
]
: [
{
name: 'link-schedule',
text: t('Link schedule'),
},
{
name: 'create-rule',
text: t('Create rule'),
},
]),
...(canUnsplitTransactions
? [
{
name: 'unsplit-transactions',
text: t('Unsplit {{count}} transactions', {
count: selectedIds.length,
}),
},
]
: []),
]),
]}
/>
);
const scheduleActions: ContextMenuItem[] = [
{
name: 'view-schedule',
text: t('View Schedule'),
onClick: onViewSchedule,
hidden: selectedIds.length !== 1,
},
{
name: 'post-transaction',
text: t('Post transaction'),
onClick: () => onScheduleAction('post-transaction', selectedIds),
},
{
name: 'post-transaction-today',
text: t('Post transaction today'),
onClick: () => onScheduleAction('post-transaction-today', selectedIds),
},
{
name: 'skip',
text: t('Skip next scheduled date'),
onClick: () => onScheduleAction('skip', selectedIds),
hidden: !canBeSkipped,
},
{
name: 'complete',
text: t('Mark as completed'),
onClick: () => onScheduleAction('complete', selectedIds),
hidden: !canBeCompleted,
},
];
const transactionActions: ContextMenuItem[] = [
{
name: 'duplicate',
text: t('Duplicate'),
onClick: () => onDuplicate(selectedIds),
hidden: ambiguousDuplication,
},
{
name: 'delete',
text: t('Delete'),
onClick: () => onDelete(selectedIds),
},
{
name: 'view-schedule',
text: t('View Schedule'),
onClick: onViewSchedule,
hidden: !(selectedIds.length === 1 && linked),
},
{
name: 'unlink-schedule',
text: t('Unlink schedule'),
onClick: () => onUnlinkSchedule(selectedIds),
hidden: !linked,
},
{
name: 'link-schedule',
text: t('Link schedule'),
onClick: () => onLinkSchedule(selectedIds),
hidden: linked,
},
{
name: 'create-rule',
text: t('Create rule'),
onClick: () => onCreateRule(selectedIds),
hidden: linked,
},
{
name: 'unsplit-transactions',
text: t('Unsplit {{count}} transactions', {
count: selectedIds.length,
}),
onClick: () => onMakeAsNonSplitTransactions(selectedIds),
hidden: !canUnsplitTransactions,
},
];
useContextMenu({
triggerRef: rowRef,
items: types.trans ? transactionActions : scheduleActions,
});
}
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest';
import {
addItems,
closeContextMenu,
getInitialState,
reducer,
setContextMenuPosition,
} from './contextMenuSlice';
import type { ContextMenuItem } from './types';
describe('contextMenuSlice', () => {
it('should return initial state', () => {
expect(getInitialState()).toEqual({
isOpen: false,
position: { x: 0, y: 0 },
items: [],
});
});
describe('addItems', () => {
it('should add items to the state', () => {
const initialState = getInitialState();
const items: ContextMenuItem[] = [
{ name: 'test1', text: 'Test 1', onClick: vi.fn() },
{ name: 'test2', text: 'Test 2', onClick: vi.fn() },
];
const state = reducer(initialState, addItems(items));
expect(state.items).toHaveLength(2);
expect(
(state.items[0] as Extract<ContextMenuItem, { name: string }>).name,
).toBe('test1');
expect(
(state.items[1] as Extract<ContextMenuItem, { name: string }>).name,
).toBe('test2');
});
it('should not add hidden items', () => {
const initialState = getInitialState();
const items: ContextMenuItem[] = [
{ name: 'test1', text: 'Test 1', onClick: vi.fn(), hidden: true },
{ name: 'test2', text: 'Test 2', onClick: vi.fn(), hidden: false },
];
const state = reducer(initialState, addItems(items));
expect(state.items).toHaveLength(1);
expect(
(state.items[0] as Extract<ContextMenuItem, { name: string }>).name,
).toBe('test2');
});
it('should add symbols (Menu.line) regardless of duplicates', () => {
const initialState = getInitialState();
const lineSymbol = Symbol('line');
const items: ContextMenuItem[] = [
lineSymbol as unknown as ContextMenuItem,
lineSymbol as unknown as ContextMenuItem,
];
const state = reducer(initialState, addItems(items));
expect(state.items).toHaveLength(2);
expect(state.items[0]).toBe(lineSymbol);
expect(state.items[1]).toBe(lineSymbol);
});
});
describe('setContextMenuPosition', () => {
it('should update position without changing isOpen', () => {
const initialState = getInitialState();
const position = { x: 100, y: 200 };
const state = reducer(initialState, setContextMenuPosition(position));
expect(state.position).toEqual(position);
expect(state.isOpen).toBe(false);
});
it('should sort items by order', () => {
const initialState = getInitialState();
const items: ContextMenuItem[] = [
{ name: 'test1', text: 'Test 1', onClick: vi.fn(), order: 2 },
{ name: 'test2', text: 'Test 2', onClick: vi.fn(), order: 1 },
{ name: 'test3', text: 'Test 3', onClick: vi.fn() }, // defaults to 0
];
let state = reducer(initialState, addItems(items));
state = reducer(state, setContextMenuPosition({ x: 0, y: 0 }));
expect(state.items).toHaveLength(3);
expect(
(state.items[0] as Extract<ContextMenuItem, { name: string }>).name,
).toBe('test3'); // order 0
expect(
(state.items[1] as Extract<ContextMenuItem, { name: string }>).name,
).toBe('test2'); // order 1
expect(
(state.items[2] as Extract<ContextMenuItem, { name: string }>).name,
).toBe('test1'); // order 2
});
});
describe('closeContextMenu', () => {
it('should set isOpen to false and clear items', () => {
let state = getInitialState();
state = reducer(
state,
addItems([{ name: 'test1', text: 'Test 1', onClick: vi.fn() }]),
);
state = reducer(state, setContextMenuPosition({ x: 10, y: 10 }));
expect(state.isOpen).toBe(true);
expect(state.items).toHaveLength(1);
state = reducer(state, closeContextMenu());
expect(state.isOpen).toBe(false);
expect(state.items).toHaveLength(0);
});
});
});
@@ -0,0 +1,56 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { ContextMenuItem } from './types';
const sliceName = 'contextMenu';
type ContextMenuState = {
isOpen: boolean;
position: { x: number; y: number };
items: ContextMenuItem[];
};
const initialState: ContextMenuState = {
isOpen: false,
position: { x: 0, y: 0 },
items: [],
};
const contextMenuSlice = createSlice({
name: sliceName,
initialState,
reducers: {
addItems(state, action: PayloadAction<ContextMenuItem[]>) {
for (const item of action.payload) {
if (typeof item === 'symbol' || !item.hidden) {
state.items.push(item);
}
}
state.items.sort((a, b) => {
const orderA = typeof a === 'object' ? (a.order ?? 0) : 0;
const orderB = typeof b === 'object' ? (b.order ?? 0) : 0;
return orderA - orderB;
});
state.isOpen = !!state.items.length;
},
setContextMenuPosition(
state,
action: PayloadAction<{
x: number;
y: number;
}>,
) {
state.position = action.payload;
},
closeContextMenu(state) {
state.isOpen = false;
state.items = [];
},
},
});
export const { name, reducer, getInitialState } = contextMenuSlice;
export const { setContextMenuPosition, closeContextMenu, addItems } =
contextMenuSlice.actions;
@@ -0,0 +1,9 @@
import type { MenuItem } from '@actual-app/components/menu';
export type ContextMenuItem =
| (Extract<MenuItem, object> & {
hidden?: boolean;
order?: number;
onClick?: () => void;
})
| Extract<MenuItem, symbol>;
@@ -0,0 +1,145 @@
import type { RefObject } from 'react';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { addItems } from '#contextmenu/contextMenuSlice';
import { useRefEventListener } from '#hooks/useRefEventListener';
import { useDispatch } from '#redux';
import { useContextMenu } from './useContextMenu';
// Mock dependencies
vi.mock('#redux', () => ({
useDispatch: vi.fn(),
useSelector: vi.fn(),
}));
vi.mock('#hooks/useRefEventListener', () => ({
useRefEventListener: vi.fn(),
}));
describe('useContextMenu', () => {
let mockDispatch: Mock;
let mockTriggerRef: {
current: { getBoundingClientRect: Mock; dispatchEvent: Mock };
};
beforeEach(() => {
vi.clearAllMocks();
mockDispatch = vi.fn();
(useDispatch as unknown as Mock).mockReturnValue(mockDispatch);
mockTriggerRef = {
current: {
getBoundingClientRect: vi.fn(() => ({ x: 100, y: 200, height: 50 })),
dispatchEvent: vi.fn(),
},
};
});
it('should register contextmenu event listener on triggerRef', () => {
const items = [{ name: 'test', text: 'Test', onClick: vi.fn() }];
renderHook(() =>
useContextMenu({
triggerRef: mockTriggerRef as unknown as RefObject<HTMLElement | null>,
items,
}),
);
expect(useRefEventListener).toHaveBeenCalledWith(
mockTriggerRef,
'contextmenu',
expect.any(Function),
);
});
it('should dispatch addItems when contextmenu event is fired and enabled is true', () => {
const items = [{ name: 'test', text: 'Test', onClick: vi.fn() }];
renderHook(() =>
useContextMenu({
triggerRef: mockTriggerRef as unknown as RefObject<HTMLElement | null>,
items,
}),
);
const listener = (useRefEventListener as Mock).mock.calls[0][2];
// Simulate event
listener({
preventDefault: vi.fn(),
clientX: 100,
clientY: 200,
} as unknown as MouseEvent);
expect(mockDispatch).toHaveBeenCalledWith(addItems(items));
});
it('should not dispatch addItems when enabled is false', () => {
const items = [{ name: 'test', text: 'Test', onClick: vi.fn() }];
renderHook(() =>
useContextMenu({
triggerRef: mockTriggerRef as unknown as RefObject<HTMLElement | null>,
enabled: false,
items,
}),
);
const listener = (useRefEventListener as Mock).mock.calls[0][2];
// Simulate event
listener({
preventDefault: vi.fn(),
clientX: 100,
clientY: 200,
} as unknown as MouseEvent);
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should provide handleContextMenu callback that dispatches contextmenu event', () => {
const items = [{ name: 'test', text: 'Test', onClick: vi.fn() }];
const { result } = renderHook(() =>
useContextMenu({
triggerRef: mockTriggerRef as unknown as RefObject<HTMLElement | null>,
items,
}),
);
act(() => {
result.current.handleContextMenu();
});
expect(mockTriggerRef.current.getBoundingClientRect).toHaveBeenCalled();
expect(mockTriggerRef.current.dispatchEvent).toHaveBeenCalled();
const dispatchedEvent =
mockTriggerRef.current.dispatchEvent.mock.calls[0][0];
expect(dispatchedEvent.type).toBe('contextmenu');
expect(dispatchedEvent.clientX).toBe(100);
expect(dispatchedEvent.clientY).toBe(250); // y + height (200 + 50)
});
it('handleContextMenu should do nothing if enabled is false', () => {
const items = [{ name: 'test', text: 'Test', onClick: vi.fn() }];
const { result } = renderHook(() =>
useContextMenu({
triggerRef: mockTriggerRef as unknown as RefObject<HTMLElement | null>,
enabled: false,
items,
}),
);
act(() => {
result.current.handleContextMenu();
});
expect(mockTriggerRef.current.dispatchEvent).not.toHaveBeenCalled();
});
});
@@ -1,36 +1,58 @@
import { useState } from 'react';
import type { MouseEventHandler } from 'react';
import { useCallback, useRef } from 'react';
import type { RefObject } from 'react';
export function useContextMenu() {
const [menuOpen, setMenuOpen] = useState(false);
const [asContextMenu, setAsContextMenu] = useState(false);
const [position, setPosition] = useState({ crossOffset: 0, offset: 0 });
import type { Falsy } from '@actual-app/core/types/util';
const handleContextMenu: MouseEventHandler<HTMLElement> = e => {
e.preventDefault();
setAsContextMenu(true);
import {
addItems,
setContextMenuPosition,
} from '#contextmenu/contextMenuSlice';
import type { ContextMenuItem } from '#contextmenu/types';
import { useRefEventListener } from '#hooks/useRefEventListener';
import { useDispatch } from '#redux';
const rect = e.currentTarget.getBoundingClientRect();
setPosition({
crossOffset: e.clientX - rect.left,
offset: e.clientY - rect.bottom,
});
setMenuOpen(true);
};
type UseContextMenuProps = {
triggerRef: RefObject<HTMLElement | null>;
enabled?: boolean;
items: Falsy<ContextMenuItem>[];
};
const resetPosition = (crossOffset = 0, offset = 0) => {
setPosition({ crossOffset, offset });
};
export function useContextMenu({
triggerRef,
enabled = true,
items,
}: UseContextMenuProps) {
const dispatch = useDispatch();
return {
menuOpen,
setMenuOpen: (open: boolean) => {
setMenuOpen(open);
setAsContextMenu(false);
},
position,
handleContextMenu,
resetPosition,
asContextMenu,
};
// Store actions in a ref to avoid re-binding the event listener on every render
const actionsRef = useRef(items);
actionsRef.current = items;
const processedItems = items.filter(
item => item && (typeof item === 'symbol' || !item.hidden),
) as ContextMenuItem[];
useRefEventListener(triggerRef, 'contextmenu', (e: MouseEvent) => {
if (enabled) {
e.preventDefault();
dispatch(addItems(processedItems));
dispatch(setContextMenuPosition({ x: e.clientX, y: e.clientY }));
}
});
const handleContextMenu = useCallback(() => {
if (!triggerRef.current || !enabled) return;
const rect = triggerRef.current.getBoundingClientRect();
// prefer MouseEvent bubbling over dispatching events to
// allow nesting context menu actions
triggerRef.current.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: rect.x,
clientY: rect.y + rect.height,
bubbles: true,
}),
);
}, [triggerRef, enabled]);
return { handleContextMenu };
}
@@ -17,9 +17,9 @@ export function useCursorPosition(
: null,
);
};
useRefEventListener(ref, 'focusin', update, [ref, setCursorPosition]);
useRefEventListener(ref, 'keyup', update, [ref, setCursorPosition]);
useRefEventListener(doc, 'selectionchange', update, [ref, setCursorPosition]);
useRefEventListener(ref, 'focusin', update);
useRefEventListener(ref, 'keyup', update);
useRefEventListener(doc, 'selectionchange', update);
useEffect(update, [ref, setCursorPosition]); // sync on mount
const clear = () => {
@@ -27,7 +27,7 @@ export function useCursorPosition(
setCursorPosition(null);
}
};
useRefEventListener(ref, 'focusout', clear, [setCursorPosition]);
useRefEventListener(ref, 'focusout', clear);
return [
cursorPosition,
@@ -7,11 +7,8 @@ export function useInputRefValue(
inputRef: RefObject<HTMLInputElement | null>,
): [string, (newValue: string) => void] {
const [value, setValue] = useState('');
useRefEventListener(
inputRef,
'input',
e => setValue((e.target as HTMLInputElement).value),
[setValue],
useRefEventListener(inputRef, 'input', e =>
setValue((e.target as HTMLInputElement).value),
);
useEffect(
() => setValue(inputRef.current?.value ?? ''),
@@ -1,27 +1,25 @@
import { useCallback, useEffect } from 'react';
import { useEffect } from 'react';
import type { RefObject } from 'react';
export function useRefEventListener<
ElementType extends EventTarget,
EventType extends keyof HTMLElementEventMap,
>(
ref: RefObject<ElementType | null>,
ref: RefObject<ElementType | null> | Document | Window,
event: EventType,
// oxlint-disable-next-line typescript/no-explicit-any
callback: (this: ElementType, ev: HTMLElementEventMap[EventType]) => any,
// oxlint-disable-next-line typescript/no-explicit-any
deps: any[],
) {
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps
const _callback = useCallback(callback, deps);
useEffect(() => {
const el = ref.current;
const el =
ref instanceof Document || ref instanceof Window ? ref : ref.current;
if (!el) return;
const callbackRef = _callback as EventListener; // closure?
const callbackRef = callback as EventListener; // closure?
el.addEventListener(event, callbackRef);
return () => {
el.removeEventListener(event, callbackRef);
};
}, [ref, event, _callback]);
}, [ref, event, callback]);
}
@@ -18,6 +18,10 @@ import {
name as budgetfilesSliceName,
reducer as budgetfilesSliceReducer,
} from '#budgetfiles/budgetfilesSlice';
import {
name as contextMenuSliceName,
reducer as contextMenuSliceReducer,
} from '#contextmenu/contextMenuSlice';
import {
name as modalsSliceName,
reducer as modalsSliceReducer,
@@ -44,6 +48,7 @@ const rootReducer = combineReducers({
[accountsSliceName]: accountsSliceReducer,
[appSliceName]: appSliceReducer,
[budgetfilesSliceName]: budgetfilesSliceReducer,
[contextMenuSliceName]: contextMenuSliceReducer,
[modalsSliceName]: modalsSliceReducer,
[notificationsSliceName]: notificationsSliceReducer,
[prefsSliceName]: prefsSliceReducer,
+9 -3
View File
@@ -1,10 +1,16 @@
const isWindows =
navigator.platform && navigator.platform.toLowerCase() === 'win32';
typeof navigator !== 'undefined' &&
navigator.platform &&
navigator.platform.toLowerCase() === 'win32';
const isMac =
navigator.platform && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
typeof navigator !== 'undefined' &&
navigator.platform &&
navigator.platform.toUpperCase().indexOf('MAC') >= 0;
export const isPlaywright = navigator.userAgent.includes('playwright');
export const isPlaywright =
typeof navigator !== 'undefined' &&
navigator.userAgent.includes('playwright');
export const OS: 'windows' | 'mac' | 'linux' | 'unknown' = isWindows
? 'windows'
+2
View File
@@ -24,3 +24,5 @@ export type AtLeastOne<T extends Record<string, unknown>> =
? Pick<T, K & keyof T> & Partial<T>
: never
: never;
export type Falsy<T> = T | undefined | null | false | '' | 0 | -0 | 0n;
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [alecbakholdin]
---
Improved context menu ergonomics under the hood