Add Report pages (#6411)

* Adding multiple report pages

* Adding release notes

* Updating release note number

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

* Fixing deletion id, allowing empty dashboard name, adding custom report dashboard saving, new dashboard default to empty

* Update VRT snapshots for command bar, payees, and schedules tests

* Update VRT snapshots for payees page visuals and search functionality tests

* Towards move/copy logic (need widget meta copy still!)

* refactor move widget to use add and remove

* Move/Copy modal

* fixes for rename duplicate calls, rename focus issue, and deletion undefined issue

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

* some bug/clarity fixes

* better type discipline, dashboard_pages schema, PR review fixes

* re-org of dashboard pages into dropdown, better mobile support, rename moved to title icon

* dashboard spacing fix (even for ridiculously long names), widget type-checking function

* Fix translation interpolation

* Fixing copy vs. move filename, removing old rename modal, minor review tweaks

* overview change simplification, routing error handling, move -> copy migration

* renaming for dashboard pages and error handling

* abstracting out `isWidgetType` function

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

* Reorganizing dashboard selector and vertical separator, fix widget tombstoning and undoability

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

* fix dashboard not found spinner, fix dashboard deletion redirect, add SaveReportWrapper

* fix some deletion navigation issues and idioms

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

* Translate 'modified' status in SaveReport component

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Will Thomas
2026-01-13 16:41:47 -06:00
committed by GitHub
parent 93cce07542
commit c1720f35fd
38 changed files with 944 additions and 67 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -20,6 +20,7 @@ import { ConfirmDeleteModal } from './modals/ConfirmDeleteModal';
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
import { CopyWidgetToDashboardModal } from './modals/CopyWidgetToDashboardModal';
import { CoverModal } from './modals/CoverModal';
import { CreateAccountModal } from './modals/CreateAccountModal';
import { CreateEncryptionKeyModal } from './modals/CreateEncryptionKeyModal';
@@ -148,6 +149,9 @@ export function Modals() {
case 'confirm-delete':
return <ConfirmDeleteModal key={key} {...modal.options} />;
case 'copy-widget-to-dashboard':
return <CopyWidgetToDashboardModal key={key} {...modal.options} />;
case 'load-backup':
return (
<LoadBackupModal

View File

@@ -0,0 +1,72 @@
import { useMemo, type ComponentProps } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { Menu } from '@actual-app/components/menu';
import { View } from '@actual-app/components/view';
import {
Modal,
ModalCloseButton,
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { useDashboardPages } from '@desktop-client/hooks/useDashboard';
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
type CopyWidgetToDashboardModalProps = Extract<
ModalType,
{ name: 'copy-widget-to-dashboard' }
>['options'];
export function CopyWidgetToDashboardModal({
onSelect,
}: CopyWidgetToDashboardModalProps) {
const { t } = useTranslation();
const { data: dashboard_pages = [] } = useDashboardPages();
const items: ComponentProps<typeof Menu<string>>['items'] = useMemo(
() => dashboard_pages.map(d => ({ name: d.id, text: d.name })),
[dashboard_pages],
);
return (
<Modal name="copy-widget-to-dashboard">
{({ state: { close } }) => (
<>
<ModalHeader
title={t('Copy to dashboard')}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
{items.length ? (
<Menu
items={items}
onMenuSelect={item => {
onSelect(item);
close();
}}
/>
) : (
<View>
<Trans>No other dashboard pages available.</Trans>
</View>
)}
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 15,
}}
>
<Button onPress={close}>
<Trans>Cancel</Trans>
</Button>
</View>
</View>
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgPencil1 } from '@actual-app/components/icons/v2';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { Input } from '@actual-app/components/input';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/fetch';
import { type DashboardEntity } from 'loot-core/types/models';
type DashboardHeaderProps = {
dashboard: DashboardEntity;
};
export function DashboardHeader({ dashboard }: DashboardHeaderProps) {
const { t } = useTranslation();
const [editingName, setEditingName] = useState(false);
const handleSaveName = async (newName: string) => {
const trimmedName = newName.trim();
if (!trimmedName || trimmedName === dashboard.name) {
setEditingName(false);
return;
}
await send('dashboard-rename', {
id: dashboard.id,
name: trimmedName,
});
setEditingName(false);
};
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
whiteSpace: 'nowrap',
marginLeft: 20,
gap: 3,
'& .hover-visible': {
opacity: 0,
transition: 'opacity .25s',
},
'&:hover .hover-visible': {
opacity: 1,
},
flexGrow: 1,
flexShrink: 1,
flexBasis: 'auto',
minWidth: 0,
display: 'flex',
justifyContent: 'flex-start',
}}
>
<View
style={{
fontSize: 25,
fontWeight: 500,
flexGrow: 0,
flexShrink: 0,
flexBasis: 'auto',
}}
>
<Trans>Reports</Trans>:
</View>
{editingName ? (
<InitialFocus>
<Input
defaultValue={dashboard.name}
onEnter={handleSaveName}
onUpdate={handleSaveName}
onEscape={() => setEditingName(false)}
style={{
fontSize: 25,
fontWeight: 500,
marginTop: -3,
marginBottom: -4,
paddingTop: 2,
paddingBottom: 2,
}}
/>
</InitialFocus>
) : (
<>
<View
style={{
fontSize: 25,
fontWeight: 500,
marginRight: 5,
flexGrow: 0,
flexShrink: 1,
flexBasis: 'auto',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
}}
>
{dashboard.name}
</View>
<Button
variant="bare"
aria-label={t('Rename dashboard')}
className="hover-visible"
style={{
marginRight: 5,
}}
onPress={() => setEditingName(true)}
>
<SvgPencil1
style={{
width: 11,
height: 11,
flexGrow: 0,
flexShrink: 0,
flexBasis: 'auto',
color: theme.pageTextSubdued,
}}
/>
</Button>
</>
)}
</View>
);
}

View File

@@ -0,0 +1,117 @@
import { useRef, useState } from 'react';
import { Dialog, DialogTrigger } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgExpandArrow } from '@actual-app/components/icons/v0';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/fetch';
import { type DashboardEntity } from 'loot-core/types/models';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
type DashboardSelectorProps = {
dashboards: readonly DashboardEntity[];
currentDashboard: DashboardEntity;
};
export function DashboardSelector({
dashboards,
currentDashboard,
}: DashboardSelectorProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const triggerRef = useRef(null);
const [menuOpen, setMenuOpen] = useState(false);
const handleAddDashboard = async () => {
const defaultName = t('New dashboard');
const newId = await send('dashboard-create', { name: defaultName });
if (newId) {
navigate(`/reports/${newId}`);
}
};
return (
<DialogTrigger>
<Button
ref={triggerRef}
onPress={() => setMenuOpen(true)}
style={{
flexGrow: 1,
flexShrink: 1,
flexBasis: 'auto',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: '5px',
}}
>
<View
style={{
flexGrow: 1,
flexShrink: 1,
flexBasis: 'auto',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
textAlign: 'center',
}}
>
{currentDashboard.name}
</View>
<SvgExpandArrow
width={7}
height={7}
style={{
flexGrow: 0,
flexShrink: 0,
flexBasis: 'auto',
marginLeft: 5,
}}
/>
</Button>
{menuOpen && (
<Popover
triggerRef={triggerRef}
isOpen
onOpenChange={setMenuOpen}
placement="bottom start"
>
<Dialog>
<Menu
slot="close"
onMenuSelect={item => {
if (item === 'add-new') {
handleAddDashboard();
} else {
navigate(`/reports/${item}`);
}
setMenuOpen(false);
}}
items={[
...dashboards.map(dashboard => ({
name: dashboard.id,
text: dashboard.name,
})),
Menu.line,
{
name: 'add-new',
text: t('Add new dashboard'),
},
]}
/>
</Dialog>
</Popover>
)}
</DialogTrigger>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Dialog, DialogTrigger } from 'react-aria-components';
import { Responsive, WidthProvider, type Layout } from 'react-grid-layout';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -10,18 +10,22 @@ 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 { breakpoints } from '@actual-app/components/tokens';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/fetch';
import {
type CustomReportWidget,
type ExportImportDashboard,
type MarkdownWidget,
type Widget,
import type {
CustomReportWidget,
DashboardEntity,
ExportImportDashboard,
MarkdownWidget,
Widget,
} from 'loot-core/types/models';
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
import { DashboardHeader } from './DashboardHeader';
import { DashboardSelector } from './DashboardSelector';
import { LoadingIndicator } from './LoadingIndicator';
import { CalendarCard } from './reports/CalendarCard';
import { CashFlowCard } from './reports/CashFlowCard';
@@ -35,13 +39,12 @@ import './overview.scss';
import { SummaryCard } from './reports/SummaryCard';
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
import {
MobilePageHeader,
Page,
PageHeader,
} from '@desktop-client/components/Page';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useDashboard } from '@desktop-client/hooks/useDashboard';
import {
useDashboard,
useDashboardPages,
} from '@desktop-client/hooks/useDashboard';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useReports } from '@desktop-client/hooks/useReports';
@@ -59,7 +62,11 @@ function isCustomReportWidget(widget: Widget): widget is CustomReportWidget {
return widget.type === 'custom-report';
}
export function Overview() {
type OverviewProps = {
dashboard: DashboardEntity;
};
export function Overview({ dashboard }: OverviewProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
@@ -76,12 +83,16 @@ export function Overview() {
const { data: customReports, isLoading: isCustomReportsLoading } =
useReports();
const { data: widgets, isLoading: isWidgetsLoading } = useDashboard();
const customReportMap = useMemo(
() => new Map(customReports.map(report => [report.id, report])),
[customReports],
);
const { data: dashboard_pages = [] } = useDashboardPages();
const { data: widgets, isLoading: isWidgetsLoading } = useDashboard(
dashboard.id,
);
const isLoading = isCustomReportsLoading || isWidgetsLoading;
@@ -179,7 +190,7 @@ export function Overview() {
const onResetDashboard = async () => {
setIsImporting(true);
await send('dashboard-reset');
await send('dashboard-reset', dashboard.id);
setIsImporting(false);
onDispatchSucessNotification(
@@ -215,6 +226,7 @@ export function Overview() {
width: 4,
height: 2,
meta,
dashboard_page_id: dashboard.id,
});
};
@@ -288,7 +300,10 @@ export function Overview() {
closeNotifications();
setIsImporting(true);
const res = await send('dashboard-import', { filepath });
const res = await send('dashboard-import', {
filepath,
dashboardPageId: dashboard.id,
});
setIsImporting(false);
if ('error' in res) {
@@ -346,6 +361,23 @@ export function Overview() {
});
};
const onCopyWidget = (widgetId: string, targetDashboardId: string) => {
send('dashboard-copy-widget', {
widgetId,
targetDashboardPageId: targetDashboardId,
});
};
const onDeleteDashboard = async (id: string) => {
await send('dashboard-delete', id);
const next_dashboard = dashboard_pages.find(d => d.id !== id);
// NOTE: This should hold since invariant dashboard_pages > 1
if (next_dashboard) {
navigate(`/reports/${next_dashboard.id}`);
}
};
const accounts = useAccounts();
if (isLoading) {
@@ -356,26 +388,69 @@ export function Overview() {
<Page
header={
isNarrowWidth ? (
<MobilePageHeader title={t('Reports')} />
<View>
<MobilePageHeader
title={
<View
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<Trans>Reports</Trans>: {dashboard.name}
</View>
}
/>
<View
style={{
padding: '5px',
borderBottom: '1px solid ' + theme.pillBorder,
}}
>
<DashboardSelector
dashboards={dashboard_pages}
currentDashboard={dashboard}
/>
</View>
</View>
) : (
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
marginRight: 15,
alignItems: 'center',
}}
>
<PageHeader title={t('Reports')} />
<DashboardHeader dashboard={dashboard} />
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
gap: 5,
alignItems: 'stretch',
}}
>
{currentBreakpoint === 'desktop' && (
<>
{/* Dashboard Selector */}
<DashboardSelector
dashboards={dashboard_pages}
currentDashboard={dashboard}
/>
<View
style={{
height: 'auto',
borderLeft: `1.5px solid ${theme.pillBorderDark}`,
borderRadius: 0.75,
marginLeft: 7,
marginRight: 7,
}}
/>
<DialogTrigger>
<Button variant="primary" isDisabled={isImporting}>
<Trans>Add new widget</Trans>
@@ -471,6 +546,7 @@ export function Overview() {
</Popover>
</DialogTrigger>
{/* The Editing Button */}
{isEditing ? (
<Button
isDisabled={isImporting}
@@ -487,6 +563,7 @@ export function Overview() {
</Button>
)}
{/* The Menu */}
<DialogTrigger>
<Button variant="bare" aria-label={t('Menu')}>
<SvgDotsHorizontalTriple
@@ -510,6 +587,9 @@ export function Overview() {
case 'import':
onImport();
break;
case 'delete':
onDeleteDashboard(dashboard.id);
break;
default:
throw new Error(
`Unrecognized menu option: ${item}`,
@@ -533,6 +613,13 @@ export function Overview() {
text: t('Export'),
disabled: isImporting,
},
Menu.line,
{
name: 'delete',
text: t('Delete dashboard'),
disabled:
isImporting || dashboard_pages.length <= 1,
},
]}
/>
</Dialog>
@@ -577,6 +664,9 @@ export function Overview() {
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'crossover-card' &&
crossoverReportEnabled ? (
@@ -587,6 +677,9 @@ export function Overview() {
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'cash-flow-card' ? (
<CashFlowCard
@@ -595,6 +688,9 @@ export function Overview() {
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'spending-card' ? (
<SpendingCard
@@ -603,6 +699,9 @@ export function Overview() {
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'markdown-card' ? (
<MarkdownCard
@@ -610,12 +709,18 @@ export function Overview() {
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'custom-report' ? (
<CustomReportListCards
isEditing={isEditing}
report={customReportMap.get(item.meta.id)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'summary-card' ? (
<SummaryCard
@@ -624,6 +729,9 @@ export function Overview() {
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'calendar-card' ? (
<CalendarCard
@@ -633,6 +741,9 @@ export function Overview() {
firstDayOfWeekIdx={firstDayOfWeekIdx}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : item.type === 'formula-card' && formulaMode ? (
<FormulaCard
@@ -641,6 +752,9 @@ export function Overview() {
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : null}
</div>

View File

@@ -27,8 +27,8 @@ type ReportCardProps = {
disableClick?: boolean;
to?: string;
children: ReactNode;
menuItems?: ComponentProps<typeof Menu>['items'];
onMenuSelect?: ComponentProps<typeof Menu>['onMenuSelect'];
menuItems?: ComponentProps<typeof Menu<string>>['items'];
onMenuSelect?: ComponentProps<typeof Menu<string>>['onMenuSelect'];
size?: number;
style?: CSSProperties;
};

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Route, Routes } from 'react-router';
import { Overview } from './Overview';
import { Calendar } from './reports/Calendar';
import { CashFlow } from './reports/CashFlow';
import { Crossover } from './reports/Crossover';
@@ -10,6 +9,7 @@ import { Formula } from './reports/Formula';
import { NetWorth } from './reports/NetWorth';
import { Spending } from './reports/Spending';
import { Summary } from './reports/Summary';
import { ReportsDashboardRouter } from './ReportsDashboardRouter';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
@@ -18,7 +18,8 @@ export function ReportRouter() {
return (
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/" element={<ReportsDashboardRouter />} />
<Route path="/:dashboardId" element={<ReportsDashboardRouter />} />
<Route path="/net-worth" element={<NetWorth />} />
<Route path="/net-worth/:id" element={<NetWorth />} />
{crossoverReportEnabled && (

View File

@@ -24,7 +24,7 @@ import {
} from 'loot-core/types/models';
import { GraphButton } from './GraphButton';
import { SaveReport } from './SaveReport';
import { SaveReportWrapper } from './SaveReport';
import { setSessionReport } from './setSessionReport';
import { SnapshotButton } from './SnapshotButton';
@@ -40,7 +40,7 @@ type ReportTopbarProps = {
viewLabels: boolean;
onApplyFilter: (newFilter: RuleConditionEntity) => void;
onChangeViews: (viewType: string) => void;
onReportChange: ComponentProps<typeof SaveReport>['onReportChange'];
onReportChange: ComponentProps<typeof SaveReportWrapper>['onReportChange'];
isItemDisabled: (type: string) => boolean;
defaultItems: (item: string) => void;
};
@@ -243,7 +243,7 @@ export function ReportTopbar({
}}
exclude={[]}
/>
<SaveReport
<SaveReportWrapper
customReportItems={customReportItems}
report={report}
savedStatus={savedStatus}

View File

@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { Block } from '@actual-app/components/block';
import { View } from '@actual-app/components/view';
import { LoadingIndicator } from './LoadingIndicator';
import { Overview } from './Overview';
import { useDashboardPages } from '@desktop-client/hooks/useDashboard';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
export function ReportsDashboardRouter() {
const { t } = useTranslation();
const { dashboardId } = useParams<{ dashboardId?: string }>();
const navigate = useNavigate();
const { data: dashboard_pages, isLoading } = useDashboardPages();
// Redirect to first dashboard if no dashboardId in URL
useEffect(() => {
if (!dashboardId && !isLoading && dashboard_pages.length > 0) {
navigate(`/reports/${dashboard_pages[0].id}`, { replace: true });
}
}, [dashboardId, isLoading, dashboard_pages, navigate]);
// Show loading while we're fetching dashboards or redirecting
if (isLoading || (!dashboardId && dashboard_pages.length > 0)) {
return <LoadingIndicator message={t('Loading dashboards...')} />;
}
// If we have a dashboardId, render Overview with it
if (dashboardId) {
const dashboard = dashboard_pages.find(d => d.id === dashboardId);
if (dashboard) {
return <Overview dashboard={dashboard} />;
} else {
// Invalid dashboardId - show error
return (
<View
style={{
flex: 1,
gap: 20,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Block style={{ marginBottom: 20, fontSize: 18 }}>
<Trans>Dashboard not found</Trans>
</Block>
</View>
);
}
}
// No dashboards exist (NOTE: This should not happen invariant is we always should have at least 1 dashboard)
return <LoadingIndicator message={t('No dashboards available')} />;
}

View File

@@ -1,20 +1,28 @@
import React, { createRef, useRef, useState } from 'react';
import { Trans } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgExpandArrow } from '@actual-app/components/icons/v0';
import { Popover } from '@actual-app/components/popover';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { send, sendCatch } from 'loot-core/platform/client/fetch';
import { type CustomReportEntity } from 'loot-core/types/models';
import {
type CustomReportEntity,
type DashboardEntity,
} from 'loot-core/types/models';
import { LoadingIndicator } from './LoadingIndicator';
import { SaveReportChoose } from './SaveReportChoose';
import { SaveReportDelete } from './SaveReportDelete';
import { SaveReportMenu } from './SaveReportMenu';
import { SaveReportName } from './SaveReportName';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import { useDashboardPages } from '@desktop-client/hooks/useDashboard';
import { useReports } from '@desktop-client/hooks/useReports';
type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
@@ -45,13 +53,28 @@ type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
savedReport?: CustomReportEntity;
},
) => void;
dashboardPages: readonly DashboardEntity[];
};
export function SaveReportWrapper<
T extends CustomReportEntity = CustomReportEntity,
>(props: Omit<SaveReportProps<T>, 'dashboardPages'>) {
const { t } = useTranslation();
const { data, isLoading } = useDashboardPages();
if (isLoading) {
return <LoadingIndicator message={t('Loading dashboards...')} />;
}
return <SaveReport {...props} dashboardPages={data} />;
}
export function SaveReport({
customReportItems,
report,
savedStatus,
onReportChange,
dashboardPages,
}: SaveReportProps) {
const { data: listReports } = useReports();
const triggerRef = useRef(null);
@@ -63,6 +86,10 @@ export function SaveReport({
const [err, setErr] = useState('');
const [newName, setNewName] = useState(report.name ?? '');
const inputRef = createRef<HTMLInputElement>();
const { t } = useTranslation();
const [saveDashboardId, setSaveDashboardId] = useState<string | null>(
dashboardPages.length > 0 ? dashboardPages[0].id : null,
);
async function onApply(cond: string) {
const chooseSavedReport = listReports.find(r => cond === r.id);
@@ -82,6 +109,11 @@ export function SaveReport({
name: newName,
};
if (!saveDashboardId) {
setErr(t('Please select a dashboard to save the report'));
return;
}
const response = await sendCatch('report/create', newSavedReport);
if (response.error) {
@@ -89,13 +121,12 @@ export function SaveReport({
setNameMenuOpen(true);
return;
}
// Add to dashboard
await send('dashboard-add-widget', {
type: 'custom-report',
width: 4,
height: 2,
meta: { id: response.data },
dashboard_page_id: saveDashboardId,
});
setNameMenuOpen(false);
@@ -202,7 +233,11 @@ export function SaveReport({
>
{!report.id ? <Trans>Unsaved report</Trans> : report.name}&nbsp;
</Text>
{savedStatus === 'modified' && <Text>(modified)&nbsp;</Text>}
{savedStatus === 'modified' && (
<Text>
<Trans>(modified)</Trans>&nbsp;
</Text>
)}
<SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} />
</Button>
@@ -225,14 +260,44 @@ export function SaveReport({
onOpenChange={() => setNameMenuOpen(false)}
style={{ width: 325 }}
>
<SaveReportName
menuItem={menuItem}
name={newName}
setName={setNewName}
inputRef={inputRef}
onAddUpdate={onAddUpdate}
err={err}
/>
<View>
<SaveReportName
menuItem={menuItem}
name={newName}
setName={setNewName}
inputRef={inputRef}
onAddUpdate={onAddUpdate}
err={err}
/>
{menuItem === 'save-report' && (
<View>
<SpaceBetween
style={{
padding: 15,
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
<FormField style={{ flex: 1 }}>
<FormLabel
title={t('Dashboard')}
htmlFor="dashboard-select"
style={{ userSelect: 'none' }}
/>
<Select
id="dashboard-select"
value={saveDashboardId}
onChange={v => setSaveDashboardId(v)}
defaultLabel={t('None')}
options={dashboardPages.map(d => [d.id, d.name])}
style={{ marginTop: 10, width: 300 }}
/>
</FormField>
</SpaceBetween>
</View>
)}
</View>
</Popover>
<Popover

View File

@@ -42,6 +42,7 @@ import {
calendarSpreadsheet,
} from '@desktop-client/components/reports/spreadsheets/calendar-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
@@ -53,6 +54,7 @@ type CalendarCardProps = {
meta?: CalendarWidget['meta'];
onMetaChange: (newMeta: CalendarWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
};
@@ -62,6 +64,7 @@ export function CalendarCard({
meta = {},
onMetaChange,
onRemove,
onCopy,
firstDayOfWeekIdx,
}: CalendarCardProps) {
const { t } = useTranslation();
@@ -168,9 +171,13 @@ export function CalendarCard({
return data?.calendarData.length;
}, [data]);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
return (
<ReportCard
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/calendar/${widgetId}`}
menuItems={[
{
@@ -181,8 +188,10 @@ export function CalendarCard({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);

View File

@@ -31,6 +31,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { simpleCashFlow } from '@desktop-client/components/reports/spreadsheets/cash-flow-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat';
type CustomLabelProps = {
@@ -104,6 +105,7 @@ type CashFlowCardProps = {
meta?: CashFlowWidget['meta'];
onMetaChange: (newMeta: CashFlowWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function CashFlowCard({
@@ -112,12 +114,16 @@ export function CashFlowCard({
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 } =
useWidgetCopyMenu(onCopy);
useEffect(() => {
async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction');
@@ -162,8 +168,10 @@ export function CashFlowCard({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);

View File

@@ -22,6 +22,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat';
// Type for the return value of the recalculate function
@@ -55,6 +56,7 @@ type CrossoverCardProps = {
meta?: CrossoverWidget['meta'];
onMetaChange: (newMeta: CrossoverWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function CrossoverCard({
@@ -64,12 +66,16 @@ export function CrossoverCard({
meta = {},
onMetaChange,
onRemove,
onCopy,
}: CrossoverCardProps) {
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
// Calculate date range from meta or use default range
const [start, setStart] = useState<string>('');
const [end, setEnd] = useState<string>('');
@@ -206,8 +212,10 @@ export function CrossoverCard({
menuItems={[
{ name: 'rename', text: t('Rename') },
{ name: 'remove', text: t('Remove') },
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);

View File

@@ -18,6 +18,7 @@ import { MissingReportCard } from './MissingReportCard';
import { DateRange } from '@desktop-client/components/reports/DateRange';
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { calculateHasWarning } from '@desktop-client/components/reports/util';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -30,12 +31,14 @@ type CustomReportListCardsProps = {
isEditing?: boolean;
report?: CustomReportEntity;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function CustomReportListCards({
isEditing,
report,
onRemove,
onCopy,
}: CustomReportListCardsProps) {
// It's possible for a dashboard to reference a non-existing
// custom report
@@ -52,6 +55,7 @@ export function CustomReportListCards({
isEditing={isEditing}
report={report}
onRemove={onRemove}
onCopy={onCopy}
/>
);
}
@@ -60,6 +64,7 @@ function CustomReportListCardsInner({
isEditing,
report,
onRemove,
onCopy,
}: Omit<CustomReportListCardsProps, 'report'> & {
report: CustomReportEntity;
}) {
@@ -71,6 +76,9 @@ function CustomReportListCardsInner({
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
const payees = usePayees();
const accounts = useAccounts();
const categories = useCategories();
@@ -138,8 +146,10 @@ function CustomReportListCardsInner({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'remove':
onRemove();

View File

@@ -8,6 +8,7 @@ import { type FormulaWidget } from 'loot-core/types/models';
import { FormulaResult } from '@desktop-client/components/reports/FormulaResult';
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormulaExecution } from '@desktop-client/hooks/useFormulaExecution';
import { useThemeColors } from '@desktop-client/hooks/useThemeColors';
@@ -17,6 +18,7 @@ type FormulaCardProps = {
meta?: FormulaWidget['meta'];
onMetaChange: (newMeta: FormulaWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function FormulaCard({
@@ -25,9 +27,12 @@ export function FormulaCard({
meta = {},
onMetaChange,
onRemove,
onCopy,
}: FormulaCardProps) {
const { t } = useTranslation();
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
const themeColors = useThemeColors();
const containerRef = useRef<HTMLDivElement>(null);
@@ -81,8 +86,10 @@ export function FormulaCard({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);

View File

@@ -15,6 +15,7 @@ import { type MarkdownWidget } from 'loot-core/types/models';
import { NON_DRAGGABLE_AREA_CLASS_NAME } from '@desktop-client/components/reports/constants';
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import {
remarkBreaks,
sequentialNewlinesPlugin,
@@ -38,6 +39,7 @@ type MarkdownCardProps = {
meta: MarkdownWidget['meta'];
onMetaChange: (newMeta: MarkdownWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function MarkdownCard({
@@ -45,11 +47,15 @@ export function MarkdownCard({
meta,
onMetaChange,
onRemove,
onCopy,
}: MarkdownCardProps) {
const { t } = useTranslation();
const [isVisibleTextArea, setIsVisibleTextArea] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
return (
<ReportCard
isEditing={isEditing}
@@ -81,8 +87,10 @@ export function MarkdownCard({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'text-left':
onMetaChange({

View File

@@ -23,6 +23,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createSpreadsheet as netWorthSpreadsheet } from '@desktop-client/components/reports/spreadsheets/net-worth-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
@@ -34,6 +35,7 @@ type NetWorthCardProps = {
meta?: NetWorthWidget['meta'];
onMetaChange: (newMeta: NetWorthWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function NetWorthCard({
@@ -43,6 +45,7 @@ export function NetWorthCard({
meta = {},
onMetaChange,
onRemove,
onCopy,
}: NetWorthCardProps) {
const locale = useLocale();
const { t } = useTranslation();
@@ -55,6 +58,9 @@ export function NetWorthCard({
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const [isCardHovered, setIsCardHovered] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
useEffect(() => {
async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction');
@@ -114,8 +120,10 @@ export function NetWorthCard({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);

View File

@@ -18,6 +18,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
import { calculateSpendingReportTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createSpendingSpreadsheet } from '@desktop-client/components/reports/spreadsheets/spending-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat';
type SpendingCardProps = {
@@ -26,6 +27,7 @@ type SpendingCardProps = {
meta?: SpendingWidget['meta'];
onMetaChange: (newMeta: SpendingWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function SpendingCard({
@@ -34,12 +36,17 @@ export function SpendingCard({
meta = {},
onMetaChange,
onRemove,
onCopy,
}: SpendingCardProps) {
const { t } = useTranslation();
const format = useFormat();
const [isCardHovered, setIsCardHovered] = useState(false);
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
const spendingReportMode = meta?.mode ?? 'single-month';
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
@@ -83,8 +90,10 @@ export function SpendingCard({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);

View File

@@ -18,6 +18,7 @@ import { calculateTimeRange } from '@desktop-client/components/reports/reportRan
import { summarySpreadsheet } from '@desktop-client/components/reports/spreadsheets/summary-spreadsheet';
import { SummaryNumber } from '@desktop-client/components/reports/SummaryNumber';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useLocale } from '@desktop-client/hooks/useLocale';
type SummaryCardProps = {
@@ -26,6 +27,7 @@ type SummaryCardProps = {
meta?: SummaryWidget['meta'];
onMetaChange: (newMeta: SummaryWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function SummaryCard({
@@ -34,12 +36,16 @@ export function SummaryCard({
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 } =
useWidgetCopyMenu(onCopy);
useEffect(() => {
async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction');
@@ -104,8 +110,10 @@ export function SummaryCard({
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
@@ -114,8 +122,7 @@ export function SummaryCard({
onRemove();
break;
default:
console.warn(`Unrecognized menu selection: ${item}`);
break;
throw new Error(`Unrecognized menu selection: ${item}`);
}
}}
>

View File

@@ -0,0 +1,54 @@
import { type ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { type Menu } from '@actual-app/components/menu';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
type WidgetCopyMenuResult = {
/** Menu items to add to the card's menu */
menuItems: ComponentProps<typeof Menu<string>>['items'];
/** Handler for menu selection - call this from onMenuSelect */
handleMenuSelect: (item: string) => boolean;
};
export function useWidgetCopyMenu(
onCopy: (targetDashboardId: string) => void,
): WidgetCopyMenuResult {
const { t } = useTranslation();
const dispatch = useDispatch();
const menuItems: ComponentProps<typeof Menu<string>>['items'] = [
{
name: 'copy-to-dashboard',
text: t('Copy to dashboard'),
},
];
const handleMenuSelect = (item: string): boolean => {
switch (item) {
case 'copy-to-dashboard':
dispatch(
pushModal({
modal: {
name: 'copy-widget-to-dashboard',
options: {
onSelect: targetDashboardId => {
onCopy(targetDashboardId);
},
},
},
}),
);
return true;
default:
return false;
}
};
return {
menuItems,
handleMenuSelect,
};
}

View File

@@ -1,13 +1,29 @@
import { useMemo } from 'react';
import { q } from 'loot-core/shared/query';
import { type Widget } from 'loot-core/types/models';
import { type Widget, type DashboardEntity } from 'loot-core/types/models';
import { useQuery } from './useQuery';
export function useDashboard() {
const { data: queryData, isLoading } = useQuery<Widget>(
() => q('dashboard').select('*'),
export function useDashboard(dashboardPageId: string) {
const { data: queryData, isLoading } = useQuery<Widget>(() => {
return q('dashboard')
.filter({ dashboard_page_id: dashboardPageId })
.select('*');
}, [dashboardPageId]);
return useMemo(
() => ({
isLoading,
data: queryData || [],
}),
[isLoading, queryData],
);
}
export function useDashboardPages() {
const { data: queryData, isLoading } = useQuery<DashboardEntity>(
() => q('dashboard_pages').select('*'),
[],
);

View File

@@ -518,6 +518,12 @@ export type Modal =
onConfirm: () => void;
};
}
| {
name: 'copy-widget-to-dashboard';
options: {
onSelect: (dashboardId: string) => void;
};
}
| {
name: 'edit-user';
options: {