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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ import {
} from 'loot-core/types/models'; } from 'loot-core/types/models';
import { GraphButton } from './GraphButton'; import { GraphButton } from './GraphButton';
import { SaveReport } from './SaveReport'; import { SaveReportWrapper } from './SaveReport';
import { setSessionReport } from './setSessionReport'; import { setSessionReport } from './setSessionReport';
import { SnapshotButton } from './SnapshotButton'; import { SnapshotButton } from './SnapshotButton';
@@ -40,7 +40,7 @@ type ReportTopbarProps = {
viewLabels: boolean; viewLabels: boolean;
onApplyFilter: (newFilter: RuleConditionEntity) => void; onApplyFilter: (newFilter: RuleConditionEntity) => void;
onChangeViews: (viewType: string) => void; onChangeViews: (viewType: string) => void;
onReportChange: ComponentProps<typeof SaveReport>['onReportChange']; onReportChange: ComponentProps<typeof SaveReportWrapper>['onReportChange'];
isItemDisabled: (type: string) => boolean; isItemDisabled: (type: string) => boolean;
defaultItems: (item: string) => void; defaultItems: (item: string) => void;
}; };
@@ -243,7 +243,7 @@ export function ReportTopbar({
}} }}
exclude={[]} exclude={[]}
/> />
<SaveReport <SaveReportWrapper
customReportItems={customReportItems} customReportItems={customReportItems}
report={report} report={report}
savedStatus={savedStatus} 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 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 { Button } from '@actual-app/components/button';
import { SvgExpandArrow } from '@actual-app/components/icons/v0'; import { SvgExpandArrow } from '@actual-app/components/icons/v0';
import { Popover } from '@actual-app/components/popover'; 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 { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view'; import { View } from '@actual-app/components/view';
import { send, sendCatch } from 'loot-core/platform/client/fetch'; 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 { SaveReportChoose } from './SaveReportChoose';
import { SaveReportDelete } from './SaveReportDelete'; import { SaveReportDelete } from './SaveReportDelete';
import { SaveReportMenu } from './SaveReportMenu'; import { SaveReportMenu } from './SaveReportMenu';
import { SaveReportName } from './SaveReportName'; 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'; import { useReports } from '@desktop-client/hooks/useReports';
type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = { type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
@@ -45,13 +53,28 @@ type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
savedReport?: CustomReportEntity; savedReport?: CustomReportEntity;
}, },
) => void; ) => 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({ export function SaveReport({
customReportItems, customReportItems,
report, report,
savedStatus, savedStatus,
onReportChange, onReportChange,
dashboardPages,
}: SaveReportProps) { }: SaveReportProps) {
const { data: listReports } = useReports(); const { data: listReports } = useReports();
const triggerRef = useRef(null); const triggerRef = useRef(null);
@@ -63,6 +86,10 @@ export function SaveReport({
const [err, setErr] = useState(''); const [err, setErr] = useState('');
const [newName, setNewName] = useState(report.name ?? ''); const [newName, setNewName] = useState(report.name ?? '');
const inputRef = createRef<HTMLInputElement>(); 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) { async function onApply(cond: string) {
const chooseSavedReport = listReports.find(r => cond === r.id); const chooseSavedReport = listReports.find(r => cond === r.id);
@@ -82,6 +109,11 @@ export function SaveReport({
name: newName, name: newName,
}; };
if (!saveDashboardId) {
setErr(t('Please select a dashboard to save the report'));
return;
}
const response = await sendCatch('report/create', newSavedReport); const response = await sendCatch('report/create', newSavedReport);
if (response.error) { if (response.error) {
@@ -89,13 +121,12 @@ export function SaveReport({
setNameMenuOpen(true); setNameMenuOpen(true);
return; return;
} }
// Add to dashboard
await send('dashboard-add-widget', { await send('dashboard-add-widget', {
type: 'custom-report', type: 'custom-report',
width: 4, width: 4,
height: 2, height: 2,
meta: { id: response.data }, meta: { id: response.data },
dashboard_page_id: saveDashboardId,
}); });
setNameMenuOpen(false); setNameMenuOpen(false);
@@ -202,7 +233,11 @@ export function SaveReport({
> >
{!report.id ? <Trans>Unsaved report</Trans> : report.name}&nbsp; {!report.id ? <Trans>Unsaved report</Trans> : report.name}&nbsp;
</Text> </Text>
{savedStatus === 'modified' && <Text>(modified)&nbsp;</Text>} {savedStatus === 'modified' && (
<Text>
<Trans>(modified)</Trans>&nbsp;
</Text>
)}
<SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} /> <SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} />
</Button> </Button>
@@ -225,14 +260,44 @@ export function SaveReport({
onOpenChange={() => setNameMenuOpen(false)} onOpenChange={() => setNameMenuOpen(false)}
style={{ width: 325 }} style={{ width: 325 }}
> >
<SaveReportName <View>
menuItem={menuItem} <SaveReportName
name={newName} menuItem={menuItem}
setName={setNewName} name={newName}
inputRef={inputRef} setName={setNewName}
onAddUpdate={onAddUpdate} inputRef={inputRef}
err={err} 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>
<Popover <Popover

View File

@@ -42,6 +42,7 @@ import {
calendarSpreadsheet, calendarSpreadsheet,
} from '@desktop-client/components/reports/spreadsheets/calendar-spreadsheet'; } from '@desktop-client/components/reports/spreadsheets/calendar-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport'; 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 { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs'; import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { useNavigate } from '@desktop-client/hooks/useNavigate';
@@ -53,6 +54,7 @@ type CalendarCardProps = {
meta?: CalendarWidget['meta']; meta?: CalendarWidget['meta'];
onMetaChange: (newMeta: CalendarWidget['meta']) => void; onMetaChange: (newMeta: CalendarWidget['meta']) => void;
onRemove: () => void; onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx']; firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
}; };
@@ -62,6 +64,7 @@ export function CalendarCard({
meta = {}, meta = {},
onMetaChange, onMetaChange,
onRemove, onRemove,
onCopy,
firstDayOfWeekIdx, firstDayOfWeekIdx,
}: CalendarCardProps) { }: CalendarCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -168,9 +171,13 @@ export function CalendarCard({
return data?.calendarData.length; return data?.calendarData.length;
}, [data]); }, [data]);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
return ( return (
<ReportCard <ReportCard
isEditing={isEditing} isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/calendar/${widgetId}`} to={`/reports/calendar/${widgetId}`}
menuItems={[ menuItems={[
{ {
@@ -181,8 +188,10 @@ export function CalendarCard({
name: 'remove', name: 'remove',
text: t('Remove'), text: t('Remove'),
}, },
...copyMenuItems,
]} ]}
onMenuSelect={item => { onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) { switch (item) {
case 'rename': case 'rename':
setNameMenuOpen(true); 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 { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { simpleCashFlow } from '@desktop-client/components/reports/spreadsheets/cash-flow-spreadsheet'; import { simpleCashFlow } from '@desktop-client/components/reports/spreadsheets/cash-flow-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport'; import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat'; import { useFormat } from '@desktop-client/hooks/useFormat';
type CustomLabelProps = { type CustomLabelProps = {
@@ -104,6 +105,7 @@ type CashFlowCardProps = {
meta?: CashFlowWidget['meta']; meta?: CashFlowWidget['meta'];
onMetaChange: (newMeta: CashFlowWidget['meta']) => void; onMetaChange: (newMeta: CashFlowWidget['meta']) => void;
onRemove: () => void; onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
}; };
export function CashFlowCard({ export function CashFlowCard({
@@ -112,12 +114,16 @@ export function CashFlowCard({
meta = {}, meta = {},
onMetaChange, onMetaChange,
onRemove, onRemove,
onCopy,
}: CashFlowCardProps) { }: CashFlowCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const animationProps = useRechartsAnimation(); const animationProps = useRechartsAnimation();
const [latestTransaction, setLatestTransaction] = useState<string>(''); const [latestTransaction, setLatestTransaction] = useState<string>('');
const [nameMenuOpen, setNameMenuOpen] = useState(false); const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
useEffect(() => { useEffect(() => {
async function fetchLatestTransaction() { async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction'); const latestTrans = await send('get-latest-transaction');
@@ -162,8 +168,10 @@ export function CashFlowCard({
name: 'remove', name: 'remove',
text: t('Remove'), text: t('Remove'),
}, },
...copyMenuItems,
]} ]}
onMenuSelect={item => { onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) { switch (item) {
case 'rename': case 'rename':
setNameMenuOpen(true); 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 { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet'; import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport'; import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat'; import { useFormat } from '@desktop-client/hooks/useFormat';
// Type for the return value of the recalculate function // Type for the return value of the recalculate function
@@ -55,6 +56,7 @@ type CrossoverCardProps = {
meta?: CrossoverWidget['meta']; meta?: CrossoverWidget['meta'];
onMetaChange: (newMeta: CrossoverWidget['meta']) => void; onMetaChange: (newMeta: CrossoverWidget['meta']) => void;
onRemove: () => void; onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
}; };
export function CrossoverCard({ export function CrossoverCard({
@@ -64,12 +66,16 @@ export function CrossoverCard({
meta = {}, meta = {},
onMetaChange, onMetaChange,
onRemove, onRemove,
onCopy,
}: CrossoverCardProps) { }: CrossoverCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isNarrowWidth } = useResponsive(); const { isNarrowWidth } = useResponsive();
const [nameMenuOpen, setNameMenuOpen] = useState(false); const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
// Calculate date range from meta or use default range // Calculate date range from meta or use default range
const [start, setStart] = useState<string>(''); const [start, setStart] = useState<string>('');
const [end, setEnd] = useState<string>(''); const [end, setEnd] = useState<string>('');
@@ -206,8 +212,10 @@ export function CrossoverCard({
menuItems={[ menuItems={[
{ name: 'rename', text: t('Rename') }, { name: 'rename', text: t('Rename') },
{ name: 'remove', text: t('Remove') }, { name: 'remove', text: t('Remove') },
...copyMenuItems,
]} ]}
onMenuSelect={item => { onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) { switch (item) {
case 'rename': case 'rename':
setNameMenuOpen(true); setNameMenuOpen(true);

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges'; import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createSpreadsheet as netWorthSpreadsheet } from '@desktop-client/components/reports/spreadsheets/net-worth-spreadsheet'; import { createSpreadsheet as netWorthSpreadsheet } from '@desktop-client/components/reports/spreadsheets/net-worth-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport'; import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat'; import { useFormat } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale'; import { useLocale } from '@desktop-client/hooks/useLocale';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref'; import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
@@ -34,6 +35,7 @@ type NetWorthCardProps = {
meta?: NetWorthWidget['meta']; meta?: NetWorthWidget['meta'];
onMetaChange: (newMeta: NetWorthWidget['meta']) => void; onMetaChange: (newMeta: NetWorthWidget['meta']) => void;
onRemove: () => void; onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
}; };
export function NetWorthCard({ export function NetWorthCard({
@@ -43,6 +45,7 @@ export function NetWorthCard({
meta = {}, meta = {},
onMetaChange, onMetaChange,
onRemove, onRemove,
onCopy,
}: NetWorthCardProps) { }: NetWorthCardProps) {
const locale = useLocale(); const locale = useLocale();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -55,6 +58,9 @@ export function NetWorthCard({
const [nameMenuOpen, setNameMenuOpen] = useState(false); const [nameMenuOpen, setNameMenuOpen] = useState(false);
const [isCardHovered, setIsCardHovered] = useState(false); const [isCardHovered, setIsCardHovered] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
useEffect(() => { useEffect(() => {
async function fetchLatestTransaction() { async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction'); const latestTrans = await send('get-latest-transaction');
@@ -114,8 +120,10 @@ export function NetWorthCard({
name: 'remove', name: 'remove',
text: t('Remove'), text: t('Remove'),
}, },
...copyMenuItems,
]} ]}
onMenuSelect={item => { onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) { switch (item) {
case 'rename': case 'rename':
setNameMenuOpen(true); 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 { calculateSpendingReportTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createSpendingSpreadsheet } from '@desktop-client/components/reports/spreadsheets/spending-spreadsheet'; import { createSpendingSpreadsheet } from '@desktop-client/components/reports/spreadsheets/spending-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport'; import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useFormat } from '@desktop-client/hooks/useFormat'; import { useFormat } from '@desktop-client/hooks/useFormat';
type SpendingCardProps = { type SpendingCardProps = {
@@ -26,6 +27,7 @@ type SpendingCardProps = {
meta?: SpendingWidget['meta']; meta?: SpendingWidget['meta'];
onMetaChange: (newMeta: SpendingWidget['meta']) => void; onMetaChange: (newMeta: SpendingWidget['meta']) => void;
onRemove: () => void; onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
}; };
export function SpendingCard({ export function SpendingCard({
@@ -34,12 +36,17 @@ export function SpendingCard({
meta = {}, meta = {},
onMetaChange, onMetaChange,
onRemove, onRemove,
onCopy,
}: SpendingCardProps) { }: SpendingCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const format = useFormat(); const format = useFormat();
const [isCardHovered, setIsCardHovered] = useState(false); const [isCardHovered, setIsCardHovered] = useState(false);
const [nameMenuOpen, setNameMenuOpen] = useState(false); const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
const spendingReportMode = meta?.mode ?? 'single-month'; const spendingReportMode = meta?.mode ?? 'single-month';
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {}); const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
@@ -83,8 +90,10 @@ export function SpendingCard({
name: 'remove', name: 'remove',
text: t('Remove'), text: t('Remove'),
}, },
...copyMenuItems,
]} ]}
onMenuSelect={item => { onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) { switch (item) {
case 'rename': case 'rename':
setNameMenuOpen(true); 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 { summarySpreadsheet } from '@desktop-client/components/reports/spreadsheets/summary-spreadsheet';
import { SummaryNumber } from '@desktop-client/components/reports/SummaryNumber'; import { SummaryNumber } from '@desktop-client/components/reports/SummaryNumber';
import { useReport } from '@desktop-client/components/reports/useReport'; import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
import { useLocale } from '@desktop-client/hooks/useLocale'; import { useLocale } from '@desktop-client/hooks/useLocale';
type SummaryCardProps = { type SummaryCardProps = {
@@ -26,6 +27,7 @@ type SummaryCardProps = {
meta?: SummaryWidget['meta']; meta?: SummaryWidget['meta'];
onMetaChange: (newMeta: SummaryWidget['meta']) => void; onMetaChange: (newMeta: SummaryWidget['meta']) => void;
onRemove: () => void; onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
}; };
export function SummaryCard({ export function SummaryCard({
@@ -34,12 +36,16 @@ export function SummaryCard({
meta = {}, meta = {},
onMetaChange, onMetaChange,
onRemove, onRemove,
onCopy,
}: SummaryCardProps) { }: SummaryCardProps) {
const locale = useLocale(); const locale = useLocale();
const { t } = useTranslation(); const { t } = useTranslation();
const [latestTransaction, setLatestTransaction] = useState<string>(''); const [latestTransaction, setLatestTransaction] = useState<string>('');
const [nameMenuOpen, setNameMenuOpen] = useState(false); const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
useEffect(() => { useEffect(() => {
async function fetchLatestTransaction() { async function fetchLatestTransaction() {
const latestTrans = await send('get-latest-transaction'); const latestTrans = await send('get-latest-transaction');
@@ -104,8 +110,10 @@ export function SummaryCard({
name: 'remove', name: 'remove',
text: t('Remove'), text: t('Remove'),
}, },
...copyMenuItems,
]} ]}
onMenuSelect={item => { onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) { switch (item) {
case 'rename': case 'rename':
setNameMenuOpen(true); setNameMenuOpen(true);
@@ -114,8 +122,7 @@ export function SummaryCard({
onRemove(); onRemove();
break; break;
default: default:
console.warn(`Unrecognized menu selection: ${item}`); throw new Error(`Unrecognized menu selection: ${item}`);
break;
} }
}} }}
> >

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 { useMemo } from 'react';
import { q } from 'loot-core/shared/query'; 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'; import { useQuery } from './useQuery';
export function useDashboard() { export function useDashboard(dashboardPageId: string) {
const { data: queryData, isLoading } = useQuery<Widget>( const { data: queryData, isLoading } = useQuery<Widget>(() => {
() => q('dashboard').select('*'), 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; onConfirm: () => void;
}; };
} }
| {
name: 'copy-widget-to-dashboard';
options: {
onSelect: (dashboardId: string) => void;
};
}
| { | {
name: 'edit-user'; name: 'edit-user';
options: { options: {

View File

@@ -0,0 +1,30 @@
import { v4 as uuidv4 } from 'uuid';
export default async function runMigration(db) {
db.transaction(() => {
// 1. Create dashboards table
db.execQuery(`
CREATE TABLE dashboard_pages
(id TEXT PRIMARY KEY,
name TEXT,
tombstone INTEGER DEFAULT 0);
`);
// 2. Add dashboard_page_id to dashboard (widgets) table
db.execQuery(`
ALTER TABLE dashboard ADD COLUMN dashboard_page_id TEXT;
`);
// 3. Create a default dashboard
const defaultDashboardId = uuidv4();
db.runQuery(`INSERT INTO dashboard_pages (id, name) VALUES (?, ?)`, [
defaultDashboardId,
'Main',
]);
// 4. Migrate existing widgets to the default dashboard
db.runQuery(`UPDATE dashboard SET dashboard_page_id = ?`, [
defaultDashboardId,
]);
});
}

View File

@@ -182,8 +182,14 @@ export const schema = {
goal: f('integer'), goal: f('integer'),
long_goal: f('integer'), long_goal: f('integer'),
}, },
dashboard_pages: {
id: f('id'),
name: f('string'),
tombstone: f('boolean'),
},
dashboard: { dashboard: {
id: f('id'), id: f('id'),
dashboard_page_id: f('id', { ref: 'dashboard_pages' }),
type: f('string', { required: true }), type: f('string', { required: true }),
width: f('integer', { required: true }), width: f('integer', { required: true }),
height: f('integer', { required: true }), height: f('integer', { required: true }),

View File

@@ -1,4 +1,5 @@
import isMatch from 'lodash/isMatch'; import isMatch from 'lodash/isMatch';
import { v4 as uuidv4 } from 'uuid';
import { captureException } from '../../platform/exceptions'; import { captureException } from '../../platform/exceptions';
import * as fs from '../../platform/server/fs'; import * as fs from '../../platform/server/fs';
@@ -27,6 +28,20 @@ function isExportedCustomReportWidget(
return widget.type === 'custom-report'; return widget.type === 'custom-report';
} }
function isWidgetType(type: string): type is Widget['type'] {
return [
'net-worth-card',
'cash-flow-card',
'spending-card',
'crossover-card',
'markdown-card',
'summary-card',
'calendar-card',
'formula-card',
'custom-report',
].includes(type);
}
const exportModel = { const exportModel = {
validate(dashboard: ExportImportDashboard) { validate(dashboard: ExportImportDashboard) {
requiredFields('Dashboard', dashboard, ['version', 'widgets']); requiredFields('Dashboard', dashboard, ['version', 'widgets']);
@@ -71,19 +86,7 @@ const exportModel = {
); );
} }
if ( if (!isWidgetType(widget.type)) {
![
'net-worth-card',
'cash-flow-card',
'spending-card',
'crossover-card',
'custom-report',
'markdown-card',
'summary-card',
'calendar-card',
'formula-card',
].includes(widget.type)
) {
throw new ValidationError( throw new ValidationError(
`Invalid widget.${idx}.type value ${widget.type}.`, `Invalid widget.${idx}.type value ${widget.type}.`,
); );
@@ -96,6 +99,44 @@ const exportModel = {
}, },
}; };
async function getDashboardPages() {
return db.all('SELECT * FROM dashboard_pages WHERE tombstone = 0');
}
async function createDashboardPage({ name }: { name: string }) {
const id = uuidv4();
await db.insertWithSchema('dashboard_pages', { id, name });
return id;
}
async function deleteDashboardPage(id: string) {
const res = await db.first<{ c: number }>(
'SELECT count(*) as c FROM dashboard_pages WHERE tombstone = 0',
);
if ((res?.c ?? 0) <= 1) {
throw new Error('Cannot delete the last dashboard page');
}
const deleting_widgets = await db.all<Pick<db.DbDashboard, 'id'>>(
'SELECT id FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0',
[id],
);
await batchMessages(async () => {
await db.delete_('dashboard_pages', id);
// Tombstone all widgets for this dashboard
await Promise.all(
deleting_widgets.map(({ id }) => db.delete_('dashboard', id)),
);
});
}
async function renameDashboardPage({ id, name }: { id: string; name: string }) {
await db.updateWithSchema('dashboard_pages', { id, name });
}
async function updateDashboard( async function updateDashboard(
widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[], widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[],
) { ) {
@@ -122,15 +163,21 @@ async function updateDashboardWidget(
await db.updateWithSchema('dashboard', widget); await db.updateWithSchema('dashboard', widget);
} }
async function resetDashboard() { async function resetDashboard(id: string) {
await batchMessages(async () => { await batchMessages(async () => {
const widgets = await db.selectWithSchema(
'dashboard',
'SELECT id FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0',
[id],
);
await Promise.all([ await Promise.all([
// Delete all widgets // Delete all widgets for this dashboard
db.deleteAll('dashboard'), ...widgets.map(({ id }) => db.delete_('dashboard', id)),
// Insert the default state // Insert the default state
...DEFAULT_DASHBOARD_STATE.map(widget => ...DEFAULT_DASHBOARD_STATE.map(widget =>
db.insertWithSchema('dashboard', widget), db.insertWithSchema('dashboard', { ...widget, dashboard_page_id: id }),
), ),
]); ]);
}); });
@@ -138,7 +185,7 @@ async function resetDashboard() {
async function addDashboardWidget( async function addDashboardWidget(
widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> & widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> &
Partial<Pick<Widget, 'x' | 'y'>>, Partial<Pick<Widget, 'x' | 'y'>> & { dashboard_page_id: string },
) { ) {
// If no x & y was provided - calculate it dynamically // If no x & y was provided - calculate it dynamically
// The new widget should be the very last one in the list of all widgets // The new widget should be the very last one in the list of all widgets
@@ -146,7 +193,8 @@ async function addDashboardWidget(
const data = await db.first< const data = await db.first<
Pick<db.DbDashboard, 'x' | 'y' | 'width' | 'height'> Pick<db.DbDashboard, 'x' | 'y' | 'width' | 'height'>
>( >(
'SELECT x, y, width, height FROM dashboard WHERE tombstone = 0 ORDER BY y DESC, x DESC', 'SELECT x, y, width, height FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0 ORDER BY y DESC, x DESC',
[widget.dashboard_page_id],
); );
if (!data) { if (!data) {
@@ -159,14 +207,59 @@ async function addDashboardWidget(
} }
} }
await db.insertWithSchema('dashboard', widget); const { dashboard_page_id, ...widgetWithoutDashboardPageId } = widget;
await db.insertWithSchema('dashboard', {
...widgetWithoutDashboardPageId,
dashboard_page_id,
});
} }
async function removeDashboardWidget(widgetId: string) { async function removeDashboardWidget(widgetId: string) {
await db.delete_('dashboard', widgetId); await db.delete_('dashboard', widgetId);
} }
async function importDashboard({ filepath }: { filepath: string }) { async function copyDashboardWidget({
widgetId,
targetDashboardPageId,
}: {
widgetId: string;
targetDashboardPageId: string;
}) {
// Get the widget to copy
const widget = await db.first<db.DbDashboard>(
'SELECT * FROM dashboard WHERE id = ? AND tombstone = 0',
[widgetId],
);
if (!widget) {
throw new Error(`Widget not found: ${widgetId}`);
}
await batchMessages(async () => {
// Insert the widget to target dashboard
if (isWidgetType(widget.type)) {
const newWidget = {
type: widget.type,
width: widget.width,
height: widget.height,
meta: widget.meta ? JSON.parse(widget.meta) : {},
dashboard_page_id: targetDashboardPageId,
};
await addDashboardWidget(newWidget);
} else {
throw new Error(`Unsupported widget type: ${widget.type}`);
}
});
}
async function importDashboard({
filepath,
dashboardPageId,
}: {
filepath: string;
dashboardPageId: string;
}) {
try { try {
if (!(await fs.exists(filepath))) { if (!(await fs.exists(filepath))) {
throw new Error(`File not found at the provided path: ${filepath}`); throw new Error(`File not found at the provided path: ${filepath}`);
@@ -182,10 +275,16 @@ async function importDashboard({ filepath }: { filepath: string }) {
); );
const customReportIdSet = new Set(customReportIds.map(({ id }) => id)); const customReportIdSet = new Set(customReportIds.map(({ id }) => id));
const existingWidgets = await db.selectWithSchema(
'dashboard',
'SELECT id FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0',
[dashboardPageId],
);
await batchMessages(async () => { await batchMessages(async () => {
await Promise.all([ await Promise.all([
// Delete all widgets // Delete all widgets
db.deleteAll('dashboard'), ...existingWidgets.map(({ id }) => db.delete_('dashboard', id)),
// Insert new widgets // Insert new widgets
...parsedContent.widgets.map(widget => ...parsedContent.widgets.map(widget =>
@@ -195,6 +294,7 @@ async function importDashboard({ filepath }: { filepath: string }) {
height: widget.height, height: widget.height,
x: widget.x, x: widget.x,
y: widget.y, y: widget.y,
dashboard_page_id: dashboardPageId,
meta: isExportedCustomReportWidget(widget) meta: isExportedCustomReportWidget(widget)
? { id: widget.meta.id } ? { id: widget.meta.id }
: widget.meta, : widget.meta,
@@ -246,19 +346,29 @@ async function importDashboard({ filepath }: { filepath: string }) {
} }
export type DashboardHandlers = { export type DashboardHandlers = {
'dashboard_pages-get': typeof getDashboardPages;
'dashboard-create': typeof createDashboardPage;
'dashboard-delete': typeof deleteDashboardPage;
'dashboard-rename': typeof renameDashboardPage;
'dashboard-update': typeof updateDashboard; 'dashboard-update': typeof updateDashboard;
'dashboard-update-widget': typeof updateDashboardWidget; 'dashboard-update-widget': typeof updateDashboardWidget;
'dashboard-reset': typeof resetDashboard; 'dashboard-reset': typeof resetDashboard;
'dashboard-add-widget': typeof addDashboardWidget; 'dashboard-add-widget': typeof addDashboardWidget;
'dashboard-remove-widget': typeof removeDashboardWidget; 'dashboard-remove-widget': typeof removeDashboardWidget;
'dashboard-copy-widget': typeof copyDashboardWidget;
'dashboard-import': typeof importDashboard; 'dashboard-import': typeof importDashboard;
}; };
export const app = createApp<DashboardHandlers>(); export const app = createApp<DashboardHandlers>();
app.method('dashboard_pages-get', getDashboardPages);
app.method('dashboard-create', mutator(undoable(createDashboardPage)));
app.method('dashboard-delete', mutator(undoable(deleteDashboardPage)));
app.method('dashboard-rename', mutator(undoable(renameDashboardPage)));
app.method('dashboard-update', mutator(undoable(updateDashboard))); app.method('dashboard-update', mutator(undoable(updateDashboard)));
app.method('dashboard-update-widget', mutator(undoable(updateDashboardWidget))); app.method('dashboard-update-widget', mutator(undoable(updateDashboardWidget)));
app.method('dashboard-reset', mutator(undoable(resetDashboard))); app.method('dashboard-reset', mutator(undoable(resetDashboard)));
app.method('dashboard-add-widget', mutator(undoable(addDashboardWidget))); app.method('dashboard-add-widget', mutator(undoable(addDashboardWidget)));
app.method('dashboard-remove-widget', mutator(undoable(removeDashboardWidget))); app.method('dashboard-remove-widget', mutator(undoable(removeDashboardWidget)));
app.method('dashboard-copy-widget', mutator(undoable(copyDashboardWidget)));
app.method('dashboard-import', mutator(undoable(importDashboard))); app.method('dashboard-import', mutator(undoable(importDashboard)));

View File

@@ -238,8 +238,15 @@ export type DbCustomReport = {
tombstone: 1 | 0; tombstone: 1 | 0;
}; };
export type DbDashboardPage = {
id: string;
name: string;
tombstone: 1 | 0;
};
export type DbDashboard = { export type DbDashboard = {
id: string; id: string;
dashboard_page_id: string;
type: string; type: string;
width: number; width: number;
height: number; height: number;

View File

@@ -8,6 +8,7 @@ import m1632571489012 from '../../../migrations/1632571489012_remove_cache';
import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories'; import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories';
import m1722804019000 from '../../../migrations/1722804019000_create_dashboard_table'; import m1722804019000 from '../../../migrations/1722804019000_create_dashboard_table';
import m1723665565000 from '../../../migrations/1723665565000_prefs'; import m1723665565000 from '../../../migrations/1723665565000_prefs';
import m1765518577215 from '../../../migrations/1765518577215_multiple_dashboards';
import * as fs from '../../platform/server/fs'; import * as fs from '../../platform/server/fs';
import { logger } from '../../platform/server/log'; import { logger } from '../../platform/server/log';
import * as sqlite from '../../platform/server/sqlite'; import * as sqlite from '../../platform/server/sqlite';
@@ -20,6 +21,7 @@ const javascriptMigrations = {
1722717601000: m1722717601000, 1722717601000: m1722717601000,
1722804019000: m1722804019000, 1722804019000: m1722804019000,
1723665565000: m1723665565000, 1723665565000: m1723665565000,
1765518577215: m1765518577215,
}; };
export async function withMigrationsDir( export async function withMigrationsDir(

View File

@@ -1,6 +1,12 @@
import { type CustomReportEntity } from './reports'; import { type CustomReportEntity } from './reports';
import { type RuleConditionEntity } from './rule'; import { type RuleConditionEntity } from './rule';
export type DashboardEntity = {
id: string;
name: string;
tombstone: boolean;
};
export type TimeFrame = { export type TimeFrame = {
start: string; start: string;
end: string; end: string;
@@ -19,6 +25,7 @@ type AbstractWidget<
Meta extends Record<string, unknown> | null = null, Meta extends Record<string, unknown> | null = null,
> = { > = {
id: string; id: string;
dashboard_page_id: string;
type: T; type: T;
x: number; x: number;
y: number; y: number;
@@ -93,7 +100,7 @@ type SpecializedWidget =
| CalendarWidget | CalendarWidget
| FormulaWidget; | FormulaWidget;
export type Widget = SpecializedWidget | CustomReportWidget; export type Widget = SpecializedWidget | CustomReportWidget;
export type NewWidget = Omit<Widget, 'id' | 'tombstone'>; export type NewWidget = Omit<Widget, 'id' | 'tombstone' | 'dashboard_page_id'>;
// Exported/imported (json) widget definition // Exported/imported (json) widget definition
export type ExportImportCustomReportWidget = Omit< export type ExportImportCustomReportWidget = Omit<

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [Durbatuluk1701]
---
Add multiple tabs/pages to "Reports" allowing for different widget layouts per page