mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-12 02:10:19 +00:00
[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:
co-authored by
Alec Bakholdin
autofix-ci[bot]
parent
8b5b182dbc
commit
74c925aaf6
+1
-1
@@ -83,7 +83,7 @@
|
||||
"error",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
"fixStyle": "separate-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/no-implied-eval": "error",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-24
@@ -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}
|
||||
|
||||
+90
-103
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user