diff --git a/packages/desktop-client/e2e/page-models/reports-page.js b/packages/desktop-client/e2e/page-models/reports-page.js index 1eb33f1fa3d6f6f4bed8396c168d708ec428f111..00933b4699a892c9758a4958bf86ec3da4d8f59c 100644 --- a/packages/desktop-client/e2e/page-models/reports-page.js +++ b/packages/desktop-client/e2e/page-models/reports-page.js @@ -5,22 +5,22 @@ export class ReportsPage { } async waitToLoad() { - return this.pageContent.getByRole('link', { name: /^Net/ }).waitFor(); + return this.pageContent.getByRole('button', { name: /^Net/ }).waitFor(); } async goToNetWorthPage() { - await this.pageContent.getByRole('link', { name: /^Net/ }).click(); + await this.pageContent.getByRole('button', { name: /^Net/ }).click(); return new ReportsPage(this.page); } async goToCashFlowPage() { - await this.pageContent.getByRole('link', { name: /^Cash/ }).click(); + await this.pageContent.getByRole('button', { name: /^Cash/ }).click(); return new ReportsPage(this.page); } async getAvailableReportList() { return this.pageContent - .getByRole('link') + .getByRole('button') .getByRole('heading') .allTextContents(); } diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png index 412fa61b6929eeca4e41486674d345668671f7e9..59e9e95db22991e8b2cf8a181c6672a93bde9bd6 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png index 583945c0bd5062de234c6e67d0f870cc7a565160..30ab53a5218001e7687774ff6abc351b2f9a6f88 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png index 18aa0dd90b1725191fa09997b9612e9c4300ce8a..1f44789f30e7a26be47fd0e81df8fecd54958f98 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png differ diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 922c6485329cc7045c471b4a4281cf9d8e460165..57a8c952bbcec3a8b4fed8dbb772bbdc983c4e1b 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -24,6 +24,7 @@ "@types/promise-retry": "^1.1.6", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.1", + "@types/react-grid-layout": "^1", "@types/react-modal": "^3.16.0", "@types/react-redux": "^7.1.25", "@types/uuid": "^9.0.2", @@ -58,6 +59,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-error-boundary": "^4.0.12", + "react-grid-layout": "^1.4.4", "react-hotkeys-hook": "^4.5.0", "react-i18next": "^14.1.2", "react-markdown": "^8.0.7", diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx index 317c23a761602c212a2e518d17bfb91063b219aa..161b91d1e8b19e4f6e82b336516c2a432b89b47b 100644 --- a/packages/desktop-client/src/components/common/Input.tsx +++ b/packages/desktop-client/src/components/common/Input.tsx @@ -39,6 +39,7 @@ export function Input({ onChangeValue, onUpdate, focused, + className, ...nativeProps }: InputProps) { const ref = useRef<HTMLInputElement>(null); @@ -63,7 +64,7 @@ export function Input({ }, styles.smallText, style, - )}`} + )} ${className}`} {...nativeProps} onKeyDown={e => { nativeProps.onKeyDown?.(e); diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx index 037a110d748457fdbc7891df87a1c89e048d2034..55372c02c9e268d6f3816a5ebdbc6af52ada54af 100644 --- a/packages/desktop-client/src/components/common/Menu.tsx +++ b/packages/desktop-client/src/components/common/Menu.tsx @@ -48,6 +48,7 @@ type MenuProps<T extends MenuItem = MenuItem> = { items: Array<T | typeof Menu.line>; onMenuSelect?: (itemName: T['name']) => void; style?: CSSProperties; + className?: string; getItemStyle?: (item: T) => CSSProperties; }; @@ -57,6 +58,7 @@ export function Menu<T extends MenuItem>({ items: allItems, onMenuSelect, style, + className, getItemStyle, }: MenuProps<T>) { const elRef = useRef<HTMLDivElement>(null); @@ -114,6 +116,7 @@ export function Menu<T extends MenuItem>({ return ( <View + className={className} style={{ outline: 'none', borderRadius: 4, overflow: 'hidden', ...style }} tabIndex={1} innerRef={elRef} diff --git a/packages/desktop-client/src/components/reports/ChooseGraph.tsx b/packages/desktop-client/src/components/reports/ChooseGraph.tsx index c74da46592c31385cf4534a24c124a97e1dd4bbd..527ed45acb487964e799266a9139e83c093ef77f 100644 --- a/packages/desktop-client/src/components/reports/ChooseGraph.tsx +++ b/packages/desktop-client/src/components/reports/ChooseGraph.tsx @@ -146,6 +146,7 @@ export function ChooseGraph({ viewLabels={viewLabels} showHiddenCategories={showHiddenCategories} showOffBudget={showOffBudget} + showTooltip={showTooltip} /> ); } diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index 49c708275512bb0a6da8237be2b5b90b5352b086..0a0d5f7455d5df48a5b5ce8f9dfe49640f9c21fb 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -1,40 +1,306 @@ -import React from 'react'; +import React, { useMemo, useRef, useState } from 'react'; +import { Responsive, WidthProvider, type Layout } from 'react-grid-layout'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import { css } from 'glamor'; - +import { + addNotification, + removeNotification, +} from 'loot-core/src/client/actions'; +import { useDashboard } from 'loot-core/src/client/data-hooks/dashboard'; import { useReports } from 'loot-core/src/client/data-hooks/reports'; +import { send } from 'loot-core/src/platform/client/fetch'; +import { + type CustomReportWidget, + type ExportImportDashboard, + type Widget, +} from 'loot-core/src/types/models'; import { useAccounts } from '../../hooks/useAccounts'; import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { useNavigate } from '../../hooks/useNavigate'; import { useResponsive } from '../../ResponsiveProvider'; +import { breakpoints } from '../../tokens'; import { Button } from '../common/Button2'; +import { Menu } from '../common/Menu'; +import { MenuButton } from '../common/MenuButton'; +import { Popover } from '../common/Popover'; import { View } from '../common/View'; import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs'; import { MobilePageHeader, Page, PageHeader } from '../Page'; +import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants'; +import { LoadingIndicator } from './LoadingIndicator'; import { CashFlowCard } from './reports/CashFlowCard'; import { CustomReportListCards } from './reports/CustomReportListCards'; import { NetWorthCard } from './reports/NetWorthCard'; import { SpendingCard } from './reports/SpendingCard'; +import './overview.scss'; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +function isCustomReportWidget(widget: Widget): widget is CustomReportWidget { + return widget.type === 'custom-report'; +} + +function useWidgetLayout(widgets: Widget[]): (Layout & { + type: Widget['type']; + meta: Widget['meta']; +})[] { + return widgets.map(widget => ({ + i: widget.id, + type: widget.type, + x: widget.x, + y: widget.y, + w: widget.width, + h: widget.height, + minW: isCustomReportWidget(widget) ? 2 : 3, + minH: isCustomReportWidget(widget) ? 1 : 2, + meta: widget.meta, + })); +} + export function Overview() { - const { data: customReports } = useReports(); + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const triggerRef = useRef(null); + const extraMenuTriggerRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [extraMenuOpen, setExtraMenuOpen] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [currentBreakpoint, setCurrentBreakpoint] = useState< + 'mobile' | 'desktop' + >('desktop'); + + 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 isLoading = isCustomReportsLoading || isWidgetsLoading; + const { isNarrowWidth } = useResponsive(); const navigate = useNavigate(); const location = useLocation(); sessionStorage.setItem('url', location.pathname); + const isDashboardsFeatureEnabled = useFeatureFlag('dashboards'); const spendingReportFeatureFlag = useFeatureFlag('spendingReport'); + const layout = useWidgetLayout(widgets); + + const closeNotifications = () => { + dispatch(removeNotification('import')); + }; + + // Close import notifications when doing "undo" operation + useHotkeys( + 'ctrl+z, cmd+z, meta+z', + closeNotifications, + { + scopes: ['app'], + }, + [closeNotifications], + ); + + const onDispatchSucessNotification = (message: string) => { + dispatch( + addNotification({ + id: 'import', + type: 'message', + sticky: true, + timeout: 30_000, // 30s + message, + messageActions: { + undo: () => { + closeNotifications(); + window.__actionsForMenu.undo(); + }, + }, + }), + ); + }; + + const onBreakpointChange = (breakpoint: 'mobile' | 'desktop') => { + setCurrentBreakpoint(breakpoint); + }; + + const onResetDashboard = async () => { + setIsImporting(true); + await send('dashboard-reset'); + setIsImporting(false); + + onDispatchSucessNotification( + t( + 'Dashboard has been successfully reset to default state. Don’t like what you see? You can always press [ctrl+z](#undo) to undo.', + ), + ); + }; + + const onLayoutChange = (newLayout: Layout[]) => { + if (!isEditing) { + return; + } + + send( + 'dashboard-update', + newLayout.map(item => ({ + id: item.i, + width: item.w, + height: item.h, + x: item.x, + y: item.y, + })), + ); + }; + + const onAddWidget = <T extends Widget>( + type: T['type'], + meta: T['meta'] = null, + ) => { + send('dashboard-add-widget', { + type, + width: 4, + height: 2, + meta, + }); + setMenuOpen(false); + }; + + const onRemoveWidget = (widgetId: string) => { + send('dashboard-remove-widget', widgetId); + }; + + const onExport = () => { + const widgetMap = new Map(widgets.map(item => [item.id, item])); + + const data = { + version: 1, + widgets: layout.map(item => { + const widget = widgetMap.get(item.i); + + if (!widget) { + throw new Error(`Unable to query widget: ${item.i}`); + } + + if (isCustomReportWidget(widget)) { + const customReport = customReportMap.get(widget.meta.id); + + if (!customReport) { + throw new Error(`Custom report not found for widget: ${item.i}`); + } + + return { + ...widget, + meta: customReport, + id: undefined, + tombstone: undefined, + }; + } + + return { ...widget, id: undefined, tombstone: undefined }; + }), + } satisfies ExportImportDashboard; + + window.Actual?.saveFile( + JSON.stringify(data, null, 2), + 'dashboard.json', + 'Export Dashboard', + ); + }; + const onImport = async () => { + const openFileDialog = window.Actual?.openFileDialog; + + if (!openFileDialog) { + dispatch( + addNotification({ + type: 'error', + message: t( + 'Fatal error occurred: unable to open import file dialog.', + ), + }), + ); + return; + } + + const [filepath] = await openFileDialog({ + properties: ['openFile'], + filters: [ + { + name: 'JSON files', + extensions: ['json'], + }, + ], + }); + + closeNotifications(); + setIsImporting(true); + const res = await send('dashboard-import', { filepath }); + setIsImporting(false); + + if (res.error) { + switch (res.error) { + case 'json-parse-error': + dispatch( + addNotification({ + id: 'import', + type: 'error', + message: t('Failed parsing the imported JSON.'), + }), + ); + break; + + case 'validation-error': + dispatch( + addNotification({ + id: 'import', + type: 'error', + message: res.message, + }), + ); + break; + + default: + dispatch( + addNotification({ + id: 'import', + type: 'error', + message: t('Failed importing the dashboard file.'), + }), + ); + break; + } + return; + } + + onDispatchSucessNotification( + t( + 'Dashboard has been successfully imported. Don’t like what you see? You can always press [ctrl+z](#undo) to undo.', + ), + ); + }; + const accounts = useAccounts(); + + if (isLoading) { + return <LoadingIndicator message={t('Loading reports...')} />; + } + return ( <Page header={ isNarrowWidth ? ( - <MobilePageHeader title="Reports" /> + <MobilePageHeader title={t('Reports')} /> ) : ( <View style={{ @@ -43,37 +309,219 @@ export function Overview() { marginRight: 15, }} > - <PageHeader title="Reports" /> - {!isNarrowWidth && ( - <Button - variant="primary" - onPress={() => navigate('/reports/custom')} - > - Create new custom report - </Button> - )} + <PageHeader title={t('Reports')} /> + + <View + style={{ + flexDirection: 'row', + justifyContent: 'space-between', + gap: 5, + }} + > + {currentBreakpoint === 'desktop' && ( + <> + {isEditing ? ( + <> + <Button + ref={triggerRef} + variant="primary" + isDisabled={isImporting} + onPress={() => setMenuOpen(true)} + > + <Trans>Add new widget</Trans> + </Button> + <Button + isDisabled={isImporting} + onPress={() => setIsEditing(false)} + > + <Trans>Finish editing dashboard</Trans> + </Button> + + <Popover + triggerRef={triggerRef} + isOpen={menuOpen} + onOpenChange={() => setMenuOpen(false)} + > + <Menu + onMenuSelect={item => { + if (item === 'custom-report') { + navigate('/reports/custom'); + return; + } + + function isExistingCustomReport( + name: string, + ): name is `custom-report-${string}` { + return name.startsWith('custom-report-'); + } + if (isExistingCustomReport(item)) { + const [, reportId] = item.split('custom-report-'); + onAddWidget('custom-report', { id: reportId }); + return; + } + + onAddWidget(item); + }} + items={[ + { + name: 'cash-flow-card' as const, + text: t('Cash flow graph'), + }, + { + name: 'net-worth-card' as const, + text: t('Net worth graph'), + }, + ...(spendingReportFeatureFlag + ? [ + { + name: 'spending-card' as const, + text: t('Spending analysis'), + }, + ] + : []), + { + name: 'custom-report' as const, + text: t('New custom report'), + }, + ...(customReports.length + ? ([Menu.line] satisfies Array<typeof Menu.line>) + : []), + ...customReports.map(report => ({ + name: `custom-report-${report.id}` as const, + text: report.name, + })), + ]} + /> + </Popover> + </> + ) : ( + <> + <Button + variant="primary" + isDisabled={isImporting} + onPress={() => navigate('/reports/custom')} + > + <Trans>Create new custom report</Trans> + </Button> + {isDashboardsFeatureEnabled && ( + <Button + isDisabled={isImporting} + onPress={() => setIsEditing(true)} + > + <Trans>Edit dashboard</Trans> + </Button> + )} + </> + )} + + {isDashboardsFeatureEnabled && ( + <> + <MenuButton + ref={extraMenuTriggerRef} + onPress={() => setExtraMenuOpen(true)} + /> + <Popover + triggerRef={extraMenuTriggerRef} + isOpen={extraMenuOpen} + onOpenChange={() => setExtraMenuOpen(false)} + > + <Menu + onMenuSelect={item => { + switch (item) { + case 'reset': + onResetDashboard(); + break; + case 'export': + onExport(); + break; + case 'import': + onImport(); + break; + } + setExtraMenuOpen(false); + }} + items={[ + { + name: 'reset', + text: t('Reset to default'), + disabled: isImporting, + }, + Menu.line, + { + name: 'import', + text: t('Import'), + disabled: isImporting, + }, + { + name: 'export', + text: t('Export'), + disabled: isImporting, + }, + ]} + /> + </Popover> + </> + )} + </> + )} + </View> </View> ) } - padding={0} + padding={10} style={{ paddingBottom: MOBILE_NAV_HEIGHT }} > - <View - className={`${css({ - flex: '0 0 auto', - flexDirection: isNarrowWidth ? 'column' : 'row', - flexWrap: isNarrowWidth ? 'nowrap' : 'wrap', - padding: '10', - '> a, > div': { - margin: '10', - }, - })}`} - > - <NetWorthCard accounts={accounts} /> - <CashFlowCard /> - {spendingReportFeatureFlag && <SpendingCard />} - <CustomReportListCards reports={customReports} /> - </View> + {isImporting ? ( + <LoadingIndicator message={t('Import is running...')} /> + ) : ( + <View style={{ userSelect: 'none' }}> + <ResponsiveGridLayout + breakpoints={{ desktop: breakpoints.medium, mobile: 1 }} + layouts={{ desktop: layout, mobile: layout }} + onLayoutChange={ + currentBreakpoint === 'desktop' ? onLayoutChange : undefined + } + onBreakpointChange={onBreakpointChange} + cols={{ desktop: 12, mobile: 1 }} + rowHeight={100} + draggableCancel={`.${NON_DRAGGABLE_AREA_CLASS_NAME}`} + isDraggable={currentBreakpoint === 'desktop' && isEditing} + isResizable={currentBreakpoint === 'desktop' && isEditing} + > + {layout.map(item => ( + <div key={item.i}> + {item.type === 'net-worth-card' ? ( + <NetWorthCard + isEditing={isEditing} + accounts={accounts} + onRemove={() => onRemoveWidget(item.i)} + /> + ) : item.type === 'cash-flow-card' ? ( + <CashFlowCard + isEditing={isEditing} + onRemove={() => onRemoveWidget(item.i)} + /> + ) : item.type === 'spending-card' ? ( + <SpendingCard + isEditing={isEditing} + onRemove={() => onRemoveWidget(item.i)} + /> + ) : item.type === 'custom-report' ? ( + <CustomReportListCards + isEditing={isEditing} + report={ + item.meta && 'id' in item.meta + ? customReportMap.get(item.meta.id) + : undefined + } + onRemove={() => onRemoveWidget(item.i)} + /> + ) : null} + </div> + ))} + </ResponsiveGridLayout> + </View> + )} </Page> ); } diff --git a/packages/desktop-client/src/components/reports/ReportCard.tsx b/packages/desktop-client/src/components/reports/ReportCard.tsx index 08e2bcf646bc73f83fb0d1c78e17391d07b7062d..44f32746460cfd3d9ac7691c2be9b12b150f0226 100644 --- a/packages/desktop-client/src/components/reports/ReportCard.tsx +++ b/packages/desktop-client/src/components/reports/ReportCard.tsx @@ -1,64 +1,174 @@ -import React, { type ReactNode } from 'react'; +import React, { + useRef, + useState, + type ComponentProps, + type ReactNode, +} from 'react'; import { type CustomReportEntity } from 'loot-core/src/types/models'; +import { useIsInViewport } from '../../hooks/useIsInViewport'; +import { useNavigate } from '../../hooks/useNavigate'; import { useResponsive } from '../../ResponsiveProvider'; import { type CSSProperties, theme } from '../../style'; -import { Link } from '../common/Link'; +import { Menu } from '../common/Menu'; +import { MenuButton } from '../common/MenuButton'; +import { Popover } from '../common/Popover'; import { View } from '../common/View'; +import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants'; + type ReportCardProps = { - to: string; + isEditing?: boolean; + to?: string; children: ReactNode; report?: CustomReportEntity; + menuItems?: ComponentProps<typeof Menu>['items']; + onMenuSelect?: ComponentProps<typeof Menu>['onMenuSelect']; size?: number; style?: CSSProperties; }; export function ReportCard({ + isEditing, to, report, + menuItems, + onMenuSelect, children, size = 1, style, }: ReportCardProps) { + const ref = useRef(null); + const isInViewport = useIsInViewport(ref); + const navigate = useNavigate(); const { isNarrowWidth } = useResponsive(); const containerProps = { flex: isNarrowWidth ? '1 1' : `0 0 calc(${size * 100}% / 3 - 20px)`, }; + const layoutProps = { + isEditing, + menuItems, + onMenuSelect, + }; + const content = ( <View + ref={ref} style={{ backgroundColor: theme.tableBackground, - borderRadius: 2, - height: 200, + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2, + width: '100%', + height: '100%', boxShadow: '0 2px 6px rgba(0, 0, 0, .15)', transition: 'box-shadow .25s', - '& .recharts-surface:hover': { - cursor: 'pointer', - }, - ':hover': to && { - boxShadow: '0 4px 6px rgba(0, 0, 0, .15)', + ...(isEditing + ? { + '& .recharts-surface:hover': { + cursor: 'move', + ':active': { cursor: 'grabbing' }, + }, + ':active': { cursor: 'grabbing' }, + filter: 'grayscale(1)', + } + : { + '& .recharts-surface:hover': { + cursor: 'pointer', + }, + }), + ':hover': { + ...(to ? { boxShadow: '0 4px 6px rgba(0, 0, 0, .15)' } : null), + ...(isEditing ? { cursor: 'move', filter: 'grayscale(0)' } : null), }, ...(to ? null : containerProps), ...style, }} > - {children} + {/* we render the content only if it is in the viewport + this reduces the amount of concurrent server api calls and thus + has a better performance */} + {isInViewport ? children : null} </View> ); if (to) { return ( - <Link - to={to} - report={report} - style={{ textDecoration: 'none', ...containerProps }} - > - {content} - </Link> + <Layout {...layoutProps}> + <View + role="button" + onClick={ + isEditing ? undefined : () => navigate(to, { state: { report } }) + } + style={{ + height: '100%', + width: '100%', + ':hover': { + cursor: 'pointer', + }, + }} + > + {content} + </View> + </Layout> ); } - return content; + + return <Layout {...layoutProps}>{content}</Layout>; +} + +type LayoutProps = { + children: ReactNode; +} & Pick<ReportCardProps, 'isEditing' | 'menuItems' | 'onMenuSelect'>; + +function Layout({ children, isEditing, menuItems, onMenuSelect }: LayoutProps) { + const triggerRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + + return ( + <View + style={{ + display: 'block', + height: '100%', + '& .hover-visible': { + opacity: 0, + transition: 'opacity .25s', + }, + '&:hover .hover-visible': { + opacity: 1, + }, + }} + > + {menuItems && isEditing && ( + <View + className={[ + menuOpen ? undefined : 'hover-visible', + NON_DRAGGABLE_AREA_CLASS_NAME, + ].join(' ')} + style={{ + position: 'absolute', + top: 7, + right: 3, + zIndex: 1, + }} + > + <MenuButton ref={triggerRef} onPress={() => setMenuOpen(true)} /> + <Popover + triggerRef={triggerRef} + isOpen={menuOpen} + onOpenChange={() => setMenuOpen(false)} + > + <Menu + className={NON_DRAGGABLE_AREA_CLASS_NAME} + onMenuSelect={onMenuSelect} + items={menuItems} + /> + </Popover> + </View> + )} + + {children} + </View> + ); } diff --git a/packages/desktop-client/src/components/reports/SaveReport.tsx b/packages/desktop-client/src/components/reports/SaveReport.tsx index 09f7a0492c8cd71c771f7a371990bd8a7c10ee70..5c0a454dd17802a65f1229eb3cf6196a48bbc5ed 100644 --- a/packages/desktop-client/src/components/reports/SaveReport.tsx +++ b/packages/desktop-client/src/components/reports/SaveReport.tsx @@ -71,6 +71,14 @@ export function SaveReport({ return; } + // Add to dashboard + await send('dashboard-add-widget', { + type: 'custom-report', + width: 4, + height: 2, + meta: { id: response.data }, + }); + setNameMenuOpen(false); onReportChange({ savedReport: { diff --git a/packages/desktop-client/src/components/reports/constants.ts b/packages/desktop-client/src/components/reports/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a106ad0f964b9c39d7ed824ece3f7894f88a576 --- /dev/null +++ b/packages/desktop-client/src/components/reports/constants.ts @@ -0,0 +1 @@ +export const NON_DRAGGABLE_AREA_CLASS_NAME = 'non-draggable-area'; diff --git a/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx index ef9545ded1952455ff21d88e116191175fbe4388..c5186e974d3332724221a1c73918e95292f216ad 100644 --- a/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx @@ -29,7 +29,6 @@ import { useAccounts } from '../../../hooks/useAccounts'; import { useCategories } from '../../../hooks/useCategories'; import { useNavigate } from '../../../hooks/useNavigate'; import { usePrivacyMode } from '../../../hooks/usePrivacyMode'; -import { useResponsive } from '../../../ResponsiveProvider'; import { type CSSProperties } from '../../../style'; import { theme } from '../../../style/index'; import { AlignedText } from '../../common/AlignedText'; @@ -178,7 +177,6 @@ export function BarGraph({ const categories = useCategories(); const accounts = useAccounts(); const privacyMode = usePrivacyMode(); - const { isNarrowWidth } = useResponsive(); const [pointer, setPointer] = useState(''); const yAxis = groupBy === 'Interval' ? 'date' : 'name'; @@ -279,7 +277,7 @@ export function BarGraph({ setPointer('pointer') } onClick={item => - !isNarrowWidth && + ((compact && showTooltip) || !compact) && !['Group', 'Interval'].includes(groupBy) && showActivity({ navigate, diff --git a/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx index d6b02490b9f806662159d2deaf45c2eaa61f9bd8..ebde9faf2b4c48308d2e344539677b3c49a33b42 100644 --- a/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx @@ -13,7 +13,6 @@ import { type RuleConditionEntity } from 'loot-core/types/models/rule'; import { useAccounts } from '../../../hooks/useAccounts'; import { useCategories } from '../../../hooks/useCategories'; import { useNavigate } from '../../../hooks/useNavigate'; -import { useResponsive } from '../../../ResponsiveProvider'; import { theme, type CSSProperties } from '../../../style'; import { PrivacyFilter } from '../../PrivacyFilter'; import { Container } from '../Container'; @@ -189,6 +188,7 @@ type DonutGraphProps = { viewLabels: boolean; showHiddenCategories?: boolean; showOffBudget?: boolean; + showTooltip?: boolean; }; export function DonutGraph({ @@ -201,6 +201,7 @@ export function DonutGraph({ viewLabels, showHiddenCategories, showOffBudget, + showTooltip = true, }: DonutGraphProps) { const yAxis = groupBy === 'Interval' ? 'date' : 'name'; const splitData = groupBy === 'Interval' ? 'intervalData' : 'data'; @@ -208,7 +209,6 @@ export function DonutGraph({ const navigate = useNavigate(); const categories = useCategories(); const accounts = useAccounts(); - const { isNarrowWidth } = useResponsive(); const [pointer, setPointer] = useState(''); const getVal = obj => { @@ -259,7 +259,7 @@ export function DonutGraph({ } }} onClick={item => - !isNarrowWidth && + ((compact && showTooltip) || !compact) && !['Group', 'Interval'].includes(groupBy) && showActivity({ navigate, diff --git a/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx b/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx index 162290a3e4bd34cad28e102d4c4d08b3e3edc8fa..5648b9a5e556a51974b44a05d6d7231dfb12b084 100644 --- a/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx @@ -26,7 +26,6 @@ import { useAccounts } from '../../../hooks/useAccounts'; import { useCategories } from '../../../hooks/useCategories'; import { useNavigate } from '../../../hooks/useNavigate'; import { usePrivacyMode } from '../../../hooks/usePrivacyMode'; -import { useResponsive } from '../../../ResponsiveProvider'; import { theme } from '../../../style'; import { type CSSProperties } from '../../../style'; import { AlignedText } from '../../common/AlignedText'; @@ -143,7 +142,6 @@ export function LineGraph({ const privacyMode = usePrivacyMode(); const [pointer, setPointer] = useState(''); const [tooltip, setTooltip] = useState(''); - const { isNarrowWidth } = useResponsive(); const largestValue = data.intervalData .map(c => c[balanceTypeOp]) @@ -239,7 +237,7 @@ export function LineGraph({ setTooltip(''); }, onClick: (e, payload) => - !isNarrowWidth && + ((compact && showTooltip) || !compact) && !['Group', 'Interval'].includes(groupBy) && onShowActivity(e, entry.id, payload), }} diff --git a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx index 3d84d5968f08513a1e70b2b3b8bd5d9de839ca81..5d59623ebc6fea97970172ebd4c341a3992f25dc 100644 --- a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx @@ -15,7 +15,6 @@ import { import { amountToCurrencyNoDecimal } from 'loot-core/shared/util'; import { usePrivacyMode } from '../../../hooks/usePrivacyMode'; -import { useResponsive } from '../../../ResponsiveProvider'; import { theme } from '../../../style'; import { type CSSProperties } from '../../../style'; import { AlignedText } from '../../common/AlignedText'; @@ -26,15 +25,16 @@ type NetWorthGraphProps = { style?: CSSProperties; graphData; compact: boolean; + showTooltip?: boolean; }; export function NetWorthGraph({ style, graphData, compact, + showTooltip = true, }: NetWorthGraphProps) { const privacyMode = usePrivacyMode(); - const { isNarrowWidth } = useResponsive(); const tickFormatter = tick => { const res = privacyMode @@ -151,7 +151,7 @@ export function NetWorthGraph({ tick={{ fill: theme.pageText }} tickLine={{ stroke: theme.pageText }} /> - {(!isNarrowWidth || !compact) && ( + {showTooltip && ( <Tooltip content={<CustomTooltip />} formatter={numberFormatterTooltip} diff --git a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx index 3b9bae5bfc0cd6646c006dc896414b7cce32183a..f83a297c8ffd739b72893a8f2e460df243c413b7 100644 --- a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx @@ -27,7 +27,6 @@ import { useAccounts } from '../../../hooks/useAccounts'; import { useCategories } from '../../../hooks/useCategories'; import { useNavigate } from '../../../hooks/useNavigate'; import { usePrivacyMode } from '../../../hooks/usePrivacyMode'; -import { useResponsive } from '../../../ResponsiveProvider'; import { theme } from '../../../style'; import { type CSSProperties } from '../../../style'; import { AlignedText } from '../../common/AlignedText'; @@ -171,7 +170,6 @@ export function StackedBarGraph({ const categories = useCategories(); const accounts = useAccounts(); const privacyMode = usePrivacyMode(); - const { isNarrowWidth } = useResponsive(); const [pointer, setPointer] = useState(''); const [tooltip, setTooltip] = useState(''); @@ -257,7 +255,7 @@ export function StackedBarGraph({ } }} onClick={e => - !isNarrowWidth && + ((compact && showTooltip) || !compact) && !['Group', 'Interval'].includes(groupBy) && showActivity({ navigate, diff --git a/packages/desktop-client/src/components/reports/overview.scss b/packages/desktop-client/src/components/reports/overview.scss new file mode 100644 index 0000000000000000000000000000000000000000..4af8585550fd908d86b99b4f0fbb210f2e9af45a --- /dev/null +++ b/packages/desktop-client/src/components/reports/overview.scss @@ -0,0 +1,9 @@ +@use 'react-grid-layout/css/styles.css'; + +.react-grid-item { + transition: none; +} + +.react-grid-item.react-grid-placeholder { + background-color: #8719e0; +} diff --git a/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx b/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx index 7d54f045487a9af6007e183be173a94af25f38d5..0c13c2251184052c3522d2e0ae3648bfde54be47 100644 --- a/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx +++ b/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx @@ -11,6 +11,7 @@ import { View } from '../../common/View'; import { PrivacyFilter } from '../../PrivacyFilter'; import { Change } from '../Change'; import { chartTheme } from '../chart-theme'; +import { Container } from '../Container'; import { DateRange } from '../DateRange'; import { LoadingIndicator } from '../LoadingIndicator'; import { ReportCard } from '../ReportCard'; @@ -67,7 +68,7 @@ function CustomLabel({ ); } -export function CashFlowCard() { +export function CashFlowCard({ isEditing, onRemove }) { const end = monthUtils.currentDay(); const start = monthUtils.currentMonth() + '-01'; @@ -83,7 +84,25 @@ export function CashFlowCard() { const income = graphData?.income || 0; return ( - <ReportCard to="/reports/cash-flow"> + <ReportCard + isEditing={isEditing} + to="/reports/cash-flow" + menuItems={[ + { + name: 'remove', + text: 'Remove', + }, + ]} + onMenuSelect={item => { + switch (item) { + case 'remove': + onRemove(); + break; + default: + throw new Error(`Unrecognized selection: ${item}`); + } + }} + > <View style={{ flex: 1 }} onPointerEnter={onCardHover} @@ -109,35 +128,50 @@ export function CashFlowCard() { </View> {data ? ( - <ResponsiveContainer width="100%" height="100%"> - <BarChart - data={[ - { - income, - expenses, - }, - ]} - margin={{ - top: 10, - bottom: 0, - }} - > - <Bar dataKey="income" fill={chartTheme.colors.blue} barSize={14}> - <LabelList - dataKey="income" - position="left" - content={<CustomLabel name="Income" />} - /> - </Bar> - <Bar dataKey="expenses" fill={chartTheme.colors.red} barSize={14}> - <LabelList - dataKey="expenses" - position="right" - content={<CustomLabel name="Expenses" />} - /> - </Bar> - </BarChart> - </ResponsiveContainer> + <Container style={{ height: 'auto', flex: 1 }}> + {(width, height) => ( + <ResponsiveContainer> + <BarChart + width={width} + height={height} + data={[ + { + income, + expenses, + }, + ]} + margin={{ + top: 10, + bottom: 0, + }} + > + <Bar + dataKey="income" + fill={chartTheme.colors.blue} + barSize={14} + > + <LabelList + dataKey="income" + position="left" + content={<CustomLabel name="Income" />} + /> + </Bar> + + <Bar + dataKey="expenses" + fill={chartTheme.colors.red} + barSize={14} + > + <LabelList + dataKey="expenses" + position="right" + content={<CustomLabel name="Expenses" />} + /> + </Bar> + </BarChart> + </ResponsiveContainer> + )} + </Container> ) : ( <LoadingIndicator /> )} diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx index f2869b3e44b720f3ae958fdeb99d14464796e246..678fcbcf271b9f3eded8737237f8eb5f25f47e90 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx @@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom'; import * as d from 'date-fns'; +import { calculateHasWarning } from 'loot-core/src/client/reports'; import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { amountToCurrency } from 'loot-core/src/shared/util'; @@ -23,6 +24,7 @@ import { usePayees } from '../../../hooks/usePayees'; import { useSyncedPref } from '../../../hooks/useSyncedPref'; import { useResponsive } from '../../../ResponsiveProvider'; import { theme, styles } from '../../../style'; +import { Warning } from '../../alerts'; import { AlignedText } from '../../common/AlignedText'; import { Block } from '../../common/Block'; import { Text } from '../../common/Text'; @@ -348,6 +350,12 @@ export function CustomReport() { const payees = usePayees(); const accounts = useAccounts(); + const hasWarning = calculateHasWarning(conditions, { + categories: categories.list, + payees, + accounts, + }); + const getGroupData = useMemo(() => { return createGroupedSpreadsheet({ startDate, @@ -712,36 +720,52 @@ export function CustomReport() { style={{ marginBottom: 10, marginLeft: 5, - flexShrink: 0, - flexDirection: 'row', + marginRight: 5, + gap: 10, alignItems: 'flex-start', - justifyContent: 'flex-start', + flexShrink: 0, }} > - <AppliedFilters - conditions={conditions} - onUpdate={(oldFilter, newFilter) => { - setSessionReport( - 'conditions', - conditions.map(f => (f === oldFilter ? newFilter : f)), - ); - onReportChange({ type: 'modify' }); - onUpdateFilter(oldFilter, newFilter); - }} - onDelete={deletedFilter => { - setSessionReport( - 'conditions', - conditions.filter(f => f !== deletedFilter), - ); - onDeleteFilter(deletedFilter); - onReportChange({ type: 'modify' }); - }} - conditionsOp={conditionsOp} - onConditionsOpChange={co => { - onConditionsOpChange(co); - onReportChange({ type: 'modify' }); + <View + style={{ + flexShrink: 0, + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'flex-start', }} - /> + > + <AppliedFilters + conditions={conditions} + onUpdate={(oldFilter, newFilter) => { + setSessionReport( + 'conditions', + conditions.map(f => (f === oldFilter ? newFilter : f)), + ); + onReportChange({ type: 'modify' }); + onUpdateFilter(oldFilter, newFilter); + }} + onDelete={deletedFilter => { + setSessionReport( + 'conditions', + conditions.filter(f => f !== deletedFilter), + ); + onDeleteFilter(deletedFilter); + onReportChange({ type: 'modify' }); + }} + conditionsOp={conditionsOp} + onConditionsOpChange={co => { + onConditionsOpChange(co); + onReportChange({ type: 'modify' }); + }} + /> + </View> + + {hasWarning && ( + <Warning style={{ paddingTop: 5, paddingBottom: 5 }}> + This report is configured to use a non-existing filter value + (i.e. category/account/payee). + </Warning> + )} </View> )} <View diff --git a/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx b/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx index 73edcca315fbebfd7b42dc8a9b1f081af911f868..d654d17c90e840ca47c2a6654a256f150e164ede 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx @@ -1,6 +1,9 @@ import React, { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; import { send, sendCatch } from 'loot-core/platform/client/fetch/index'; +import { addNotification } from 'loot-core/src/client/actions'; +import { calculateHasWarning } from 'loot-core/src/client/reports'; import * as monthUtils from 'loot-core/src/shared/months'; import { type CustomReportEntity } from 'loot-core/types/models/reports'; @@ -8,56 +11,76 @@ import { useAccounts } from '../../../hooks/useAccounts'; import { useCategories } from '../../../hooks/useCategories'; import { usePayees } from '../../../hooks/usePayees'; import { useSyncedPref } from '../../../hooks/useSyncedPref'; -import { useResponsive } from '../../../ResponsiveProvider'; +import { SvgExclamationSolid } from '../../../icons/v1'; import { styles } from '../../../style/index'; import { theme } from '../../../style/theme'; import { Block } from '../../common/Block'; +import { InitialFocus } from '../../common/InitialFocus'; +import { Input } from '../../common/Input'; import { Text } from '../../common/Text'; +import { Tooltip } from '../../common/Tooltip'; import { View } from '../../common/View'; +import { NON_DRAGGABLE_AREA_CLASS_NAME } from '../constants'; import { DateRange } from '../DateRange'; import { ReportCard } from '../ReportCard'; import { GetCardData } from './GetCardData'; -import { ListCardsPopover } from './ListCardsPopover'; +import { MissingReportCard } from './MissingReportCard'; -function index(data: CustomReportEntity[]): { [key: string]: boolean }[] { - return data.reduce((carry, report) => { - const reportId: string = report.id === undefined ? '' : report.id; +type CustomReportListCardsProps = { + isEditing?: boolean; + report?: CustomReportEntity; + onRemove: () => void; +}; - return { - ...carry, - [reportId]: false, - }; - }, []); +export function CustomReportListCards({ + isEditing, + report, + onRemove, +}: CustomReportListCardsProps) { + // It's possible for a dashboard to reference a non-existing + // custom report + if (!report) { + return ( + <MissingReportCard isEditing={isEditing} onRemove={onRemove}> + This custom report has been deleted. + </MissingReportCard> + ); + } + + return ( + <CustomReportListCardsInner + isEditing={isEditing} + report={report} + onRemove={onRemove} + /> + ); } -export function CustomReportListCards({ - reports, -}: { - reports: CustomReportEntity[]; +function CustomReportListCardsInner({ + isEditing, + report, + onRemove, +}: Omit<CustomReportListCardsProps, 'report'> & { + report: CustomReportEntity; }) { - const result: { [key: string]: boolean }[] = index(reports); - const [reportMenu, setReportMenu] = useState(result); - const [deleteMenuOpen, setDeleteMenuOpen] = useState(result); - const [nameMenuOpen, setNameMenuOpen] = useState(result); - const [err, setErr] = useState(''); - const [name, setName] = useState(''); + const dispatch = useDispatch(); + + const [nameMenuOpen, setNameMenuOpen] = useState(false); const [earliestTransaction, setEarliestTransaction] = useState(''); const payees = usePayees(); const accounts = useAccounts(); const categories = useCategories(); - const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx'); - const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0'; - const { isNarrowWidth } = useResponsive(); - const [isCardHovered, setIsCardHovered] = useState(''); + const hasWarning = calculateHasWarning(report.conditions ?? [], { + categories: categories.list, + payees, + accounts, + }); - const onDelete = async (reportData: string) => { - setName(''); - await send('report/delete', reportData); - onDeleteMenuOpen(reportData === undefined ? '' : reportData, false); - }; + const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx'); + const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0'; useEffect(() => { async function run() { @@ -67,141 +90,128 @@ export function CustomReportListCards({ run(); }, []); - const onAddUpdate = async ({ - reportData, - }: { - reportData?: CustomReportEntity; - }) => { - if (!reportData) { - return null; - } - + const onSaveName = async (name: string) => { const updatedReport = { - ...reportData, + ...report, name, }; const response = await sendCatch('report/update', updatedReport); if (response.error) { - setErr(response.error.message); - onNameMenuOpen(reportData.id === undefined ? '' : reportData.id, true); + dispatch( + addNotification({ + type: 'error', + message: `Failed saving report name: ${response.error.message}`, + }), + ); + setNameMenuOpen(true); return; } - onNameMenuOpen(reportData.id === undefined ? '' : reportData.id, false); - }; - - const onMenuSelect = async (item: string, report: CustomReportEntity) => { - if (item === 'delete') { - onMenuOpen(report.id, false); - onDeleteMenuOpen(report.id, true); - setErr(''); - } - if (item === 'rename') { - onMenuOpen(report.id, false); - onNameMenuOpen(report.id, true); - setName(report.name); - setErr(''); - } - }; - - const onMenuOpen = (item: string, state: boolean) => { - setReportMenu({ ...reportMenu, [item]: state }); + setNameMenuOpen(false); }; - const onDeleteMenuOpen = (item: string, state: boolean) => { - setDeleteMenuOpen({ ...deleteMenuOpen, [item]: state }); - }; - - const onNameMenuOpen = (item: string, state: boolean) => { - setNameMenuOpen({ ...nameMenuOpen, [item]: state }); - }; - - if (reports.length === 0) return null; return ( - <> - {reports.map((report, id) => ( + <ReportCard + isEditing={isEditing} + to="/reports/custom" + report={report} + menuItems={[ + { + name: 'rename', + text: 'Rename', + }, + { + name: 'remove', + text: 'Remove', + }, + ]} + onMenuSelect={item => { + switch (item) { + case 'remove': + onRemove(); + break; + case 'rename': + setNameMenuOpen(true); + break; + } + }} + > + <View style={{ flex: 1, padding: 10 }}> <View - key={id} style={{ - flex: isNarrowWidth ? '1 1' : `0 0 calc(100% / 3 - 20px)`, + flexShrink: 0, + paddingBottom: 5, }} > - <ReportCard to="/reports/custom" report={report}> - <View - style={{ flex: 1, padding: 10 }} - onMouseEnter={() => - setIsCardHovered(report.id === undefined ? '' : report.id) - } - onMouseLeave={() => { - setIsCardHovered(''); - onMenuOpen(report.id === undefined ? '' : report.id, false); - }} - > - <View + <View style={{ flex: 1 }}> + {nameMenuOpen ? ( + <InitialFocus> + <Input + className={NON_DRAGGABLE_AREA_CLASS_NAME} + defaultValue={report.name} + onEnter={e => + onSaveName((e.target as HTMLInputElement).value) + } + onBlur={e => onSaveName(e.target.value)} + onEscape={() => setNameMenuOpen(false)} + style={{ + fontSize: 15, + fontWeight: 500, + marginTop: -6, + marginBottom: -1, + marginLeft: -6, + width: Math.max(20, report.name.length) + 'ch', + }} + /> + </InitialFocus> + ) : ( + <Block style={{ - flexShrink: 0, - paddingBottom: 5, + ...styles.mediumText, + fontWeight: 500, + marginBottom: 5, }} + role="heading" > - <View style={{ flex: 1 }}> - <Block - style={{ - ...styles.mediumText, - fontWeight: 500, - marginBottom: 5, - }} - role="heading" - > - {report.name} - </Block> - {report.isDateStatic ? ( - <DateRange start={report.startDate} end={report.endDate} /> - ) : ( - <Text style={{ color: theme.pageTextSubdued }}> - {report.dateRange} - </Text> - )} - </View> - </View> - <GetCardData - report={report} - payees={payees} - accounts={accounts} - categories={categories} - earliestTransaction={earliestTransaction} - firstDayOfWeekIdx={firstDayOfWeekIdx} - /> - </View> - </ReportCard> - <View - style={{ - textAlign: 'right', - position: 'absolute', - right: 10, - top: 10, - }} + {report.name} + </Block> + )} + {report.isDateStatic ? ( + <DateRange start={report.startDate} end={report.endDate} /> + ) : ( + <Text style={{ color: theme.pageTextSubdued }}> + {report.dateRange} + </Text> + )} + </View> + </View> + <GetCardData + report={report} + payees={payees} + accounts={accounts} + categories={categories} + earliestTransaction={earliestTransaction} + firstDayOfWeekIdx={firstDayOfWeekIdx} + showTooltip={!isEditing} + /> + </View> + {hasWarning && ( + <View style={{ padding: 5, position: 'absolute', bottom: 0 }}> + <Tooltip + content="The widget is configured to use a non-existing filter value (i.e. category/account/payee). Edit the filters used in this report widget to remove the warning." + placement="bottom start" + style={{ ...styles.tooltip, maxWidth: 300 }} > - <ListCardsPopover - report={report} - onMenuOpen={onMenuOpen} - isCardHovered={isCardHovered} - reportMenu={reportMenu} - onMenuSelect={onMenuSelect} - nameMenuOpen={nameMenuOpen} - name={name} - setName={setName} - onAddUpdate={onAddUpdate} - err={err} - onNameMenuOpen={onNameMenuOpen} - deleteMenuOpen={deleteMenuOpen} - onDeleteMenuOpen={onDeleteMenuOpen} - onDelete={onDelete} + <SvgExclamationSolid + width={20} + height={20} + style={{ color: theme.warningText }} /> - </View> + </Tooltip> </View> - ))} - </> + )} + </ReportCard> ); } diff --git a/packages/desktop-client/src/components/reports/reports/ListCardsMenu.tsx b/packages/desktop-client/src/components/reports/reports/ListCardsMenu.tsx deleted file mode 100644 index 7ecd3542c364d63de3653ccc69f6b56f84b8eeb3..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/components/reports/reports/ListCardsMenu.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; - -import { type CustomReportEntity } from 'loot-core/types/models/reports'; - -import { Menu } from '../../common/Menu'; - -type ListCardsMenuProps = { - onMenuSelect: (item: string, report: CustomReportEntity) => void; - report: CustomReportEntity; -}; - -export function ListCardsMenu({ onMenuSelect, report }: ListCardsMenuProps) { - return ( - <Menu - onMenuSelect={item => { - onMenuSelect(item, report); - }} - items={[ - { - name: 'rename', - text: 'Rename report', - }, - { - name: 'delete', - text: 'Delete report', - }, - ]} - /> - ); -} diff --git a/packages/desktop-client/src/components/reports/reports/ListCardsPopover.tsx b/packages/desktop-client/src/components/reports/reports/ListCardsPopover.tsx deleted file mode 100644 index 7b2a2817ba240b28827eabf0571a42227157e600..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/components/reports/reports/ListCardsPopover.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { createRef, useRef } from 'react'; - -import { type CustomReportEntity } from 'loot-core/types/models/reports'; - -import { MenuButton } from '../../common/MenuButton'; -import { Popover } from '../../common/Popover'; -import { SaveReportDelete } from '../SaveReportDelete'; -import { SaveReportName } from '../SaveReportName'; - -import { ListCardsMenu } from './ListCardsMenu'; - -type ListCardsPopoverProps = { - report: CustomReportEntity; - onMenuOpen: (item: string, state: boolean) => void; - isCardHovered: string; - reportMenu: { [key: string]: boolean }[]; - onMenuSelect: (item: string, report: CustomReportEntity) => void; - nameMenuOpen: { [key: string]: boolean }[]; - onNameMenuOpen: (item: string, state: boolean) => void; - name: string; - setName: (name: string) => void; - onAddUpdate: ({ reportData }: { reportData?: CustomReportEntity }) => void; - err: string; - deleteMenuOpen: { [key: string]: boolean }[]; - onDeleteMenuOpen: (item: string, state: boolean) => void; - onDelete: (reportData: string) => void; -}; -export function ListCardsPopover({ - report, - onMenuOpen, - isCardHovered, - reportMenu, - onMenuSelect, - nameMenuOpen, - onNameMenuOpen, - name, - setName, - onAddUpdate, - err, - deleteMenuOpen, - onDeleteMenuOpen, - onDelete, -}: ListCardsPopoverProps) { - const triggerRef = useRef(null); - const inputRef = createRef<HTMLInputElement>(); - - return ( - <> - <MenuButton - aria-label="Report menu" - ref={triggerRef} - onPress={() => - onMenuOpen(report.id === undefined ? '' : report.id, true) - } - style={{ - color: isCardHovered === report.id ? 'inherit' : 'transparent', - }} - /> - - <Popover - triggerRef={triggerRef} - isOpen={ - !!(report.id && reportMenu[report.id as keyof typeof reportMenu]) - } - onOpenChange={() => - onMenuOpen(report.id === undefined ? '' : report.id, false) - } - style={{ width: 120 }} - > - <ListCardsMenu onMenuSelect={onMenuSelect} report={report} /> - </Popover> - - <Popover - triggerRef={triggerRef} - isOpen={ - !!(report.id && nameMenuOpen[report.id as keyof typeof nameMenuOpen]) - } - onOpenChange={() => - onNameMenuOpen(report.id === undefined ? '' : report.id, false) - } - style={{ width: 325 }} - > - <SaveReportName - menuItem="rename" - name={name} - setName={setName} - inputRef={inputRef} - onAddUpdate={onAddUpdate} - err={err} - report={report} - /> - </Popover> - - <Popover - triggerRef={triggerRef} - isOpen={ - !!( - report.id && - deleteMenuOpen[report.id as keyof typeof deleteMenuOpen] - ) - } - onOpenChange={() => - onDeleteMenuOpen(report.id === undefined ? '' : report.id, false) - } - style={{ width: 275, padding: 15 }} - > - <SaveReportDelete - onDelete={() => onDelete(report.id)} - onClose={() => - onDeleteMenuOpen(report.id === undefined ? '' : report.id, false) - } - name={report.name} - /> - </Popover> - </> - ); -} diff --git a/packages/desktop-client/src/components/reports/reports/MissingReportCard.tsx b/packages/desktop-client/src/components/reports/reports/MissingReportCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..427fd5ca4acc1a469bb3522ae392aea5b7a25571 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/MissingReportCard.tsx @@ -0,0 +1,46 @@ +import React, { type ReactNode } from 'react'; + +import { View } from '../../common/View'; +import { ReportCard } from '../ReportCard'; + +type MissingReportCardProps = { + isEditing?: boolean; + onRemove: () => void; + children: ReactNode; +}; + +export function MissingReportCard({ + isEditing, + onRemove, + children, +}: MissingReportCardProps) { + return ( + <ReportCard + isEditing={isEditing} + menuItems={[ + { + name: 'remove', + text: 'Remove', + }, + ]} + onMenuSelect={item => { + switch (item) { + case 'remove': + onRemove(); + break; + } + }} + > + <View + style={{ + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }} + > + {children} + </View> + </ReportCard> + ); +} diff --git a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx index 1340ba4811211da114037389e460d49b1b0a5682..4a3d0f9afbf530eedadc05dec1a5e8e9740e90da 100644 --- a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx +++ b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx @@ -151,6 +151,7 @@ export function NetWorth() { domain={{ y: [data.lowestNetWorth * 0.99, data.highestNetWorth * 1.01], }} + showTooltip={!isNarrowWidth} /> <View style={{ marginTop: 30, userSelect: 'none' }}> diff --git a/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx b/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx index c318690ec75b4a4298aa552145b9d5e125cd0e65..aab03f09920817c04ee5ddb68281446cadf635f5 100644 --- a/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx +++ b/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx @@ -3,6 +3,7 @@ import React, { useState, useMemo, useCallback } from 'react'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency } from 'loot-core/src/shared/util'; +import { useResponsive } from '../../../ResponsiveProvider'; import { styles } from '../../../style'; import { Block } from '../../common/Block'; import { View } from '../../common/View'; @@ -15,7 +16,9 @@ import { ReportCard } from '../ReportCard'; import { createSpreadsheet as netWorthSpreadsheet } from '../spreadsheets/net-worth-spreadsheet'; import { useReport } from '../useReport'; -export function NetWorthCard({ accounts }) { +export function NetWorthCard({ isEditing, accounts, onRemove }) { + const { isNarrowWidth } = useResponsive(); + const end = monthUtils.currentMonth(); const start = monthUtils.subMonths(end, 5); const [isCardHovered, setIsCardHovered] = useState(false); @@ -29,7 +32,25 @@ export function NetWorthCard({ accounts }) { const data = useReport('net_worth', params); return ( - <ReportCard size={2} to="/reports/net-worth"> + <ReportCard + isEditing={isEditing} + to="/reports/net-worth" + menuItems={[ + { + name: 'remove', + text: 'Remove', + }, + ]} + onMenuSelect={item => { + switch (item) { + case 'remove': + onRemove(); + break; + default: + throw new Error(`Unrecognized selection: ${item}`); + } + }} + > <View style={{ flex: 1 }} onPointerEnter={onCardHover} @@ -71,6 +92,7 @@ export function NetWorthCard({ accounts }) { end={end} graphData={data.graphData} compact={true} + showTooltip={!isEditing && !isNarrowWidth} style={{ height: 'auto', flex: 1 }} /> ) : ( diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx index 863157c48acb46db1f299b16ed0a2d617c7b7bdb..9127e3040d2d9ed08acfc838ae6f51e7cf10d549 100644 --- a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx +++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx @@ -3,6 +3,7 @@ import React, { useState, useMemo } from 'react'; import * as monthUtils from 'loot-core/src/shared/months'; import { amountToCurrency } from 'loot-core/src/shared/util'; +import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; import { useLocalPref } from '../../../hooks/useLocalPref'; import { styles } from '../../../style/styles'; import { theme } from '../../../style/theme'; @@ -16,7 +17,14 @@ import { ReportCard } from '../ReportCard'; import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'; import { useReport } from '../useReport'; -export function SpendingCard() { +import { MissingReportCard } from './MissingReportCard'; + +type SpendingCardProps = { + isEditing?: boolean; + onRemove: () => void; +}; + +export function SpendingCard({ isEditing, onRemove }: SpendingCardProps) { const [isCardHovered, setIsCardHovered] = useState(false); const [spendingReportFilter = ''] = useLocalPref('spendingReportFilter'); const [spendingReportTime = 'lastMonth'] = useLocalPref('spendingReportTime'); @@ -46,8 +54,36 @@ export function SpendingCard() { data.intervalData[todayDay][spendingReportCompare]; const showLastMonth = data && Math.abs(data.intervalData[27].lastMonth) > 0; + const spendingReportFeatureFlag = useFeatureFlag('spendingReport'); + + if (!spendingReportFeatureFlag) { + return ( + <MissingReportCard isEditing={isEditing} onRemove={onRemove}> + The experimental spending report feature has not been enabled. + </MissingReportCard> + ); + } + return ( - <ReportCard to="/reports/spending"> + <ReportCard + isEditing={isEditing} + to="/reports/spending" + menuItems={[ + { + name: 'remove', + text: 'Remove', + }, + ]} + onMenuSelect={item => { + switch (item) { + case 'remove': + onRemove(); + break; + default: + throw new Error(`Unrecognized selection: ${item}`); + } + }} + > <View style={{ flex: 1 }} onPointerEnter={() => setIsCardHovered(true)} diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index ab49f18c426f77ba4ae8d7ca5d3c7f07ef2fcf8e..0fb7cadaa22b91f6d986a2b6b3571d4c3cfff2bf 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -89,6 +89,9 @@ export function ExperimentalFeatures() { Goal templates </FeatureToggle> <FeatureToggle flag="simpleFinSync">SimpleFIN sync</FeatureToggle> + <FeatureToggle flag="dashboards"> + Customizable reports page (dashboards) + </FeatureToggle> </View> ) : ( <Link diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index f6756404513f710a1bbf10ff4981fc7238e709f3..168c80d7fac8386af8d55a97c8641d65f0d8cd46 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -8,6 +8,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = { goalTemplatesEnabled: false, spendingReport: false, simpleFinSync: false, + dashboards: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/desktop-client/src/hooks/useIsInViewport.ts b/packages/desktop-client/src/hooks/useIsInViewport.ts new file mode 100644 index 0000000000000000000000000000000000000000..58c2be240f3778b37f802bacf3c79d605466a0b7 --- /dev/null +++ b/packages/desktop-client/src/hooks/useIsInViewport.ts @@ -0,0 +1,32 @@ +import React, { useEffect, useMemo, useState, type RefObject } from 'react'; + +/** + * Check if the given element (by ref) is visible in the viewport. + */ +export function useIsInViewport(ref: RefObject<Element>) { + const [isIntersecting, setIsIntersecting] = useState(false); + + const observer = useMemo( + () => + new IntersectionObserver(([entry]) => + setIsIntersecting(entry.isIntersecting), + ), + [], + ); + + useEffect(() => { + const view = ref.current; + + if (!view) { + return; + } + + observer.observe(view); + + return () => { + observer.disconnect(); + }; + }, [ref, observer]); + + return isIntersecting; +} diff --git a/packages/loot-core/migrations/1722804019000_create_dashboard_table.js b/packages/loot-core/migrations/1722804019000_create_dashboard_table.js new file mode 100644 index 0000000000000000000000000000000000000000..228b85ede0d6948a970c699d45fac0f0eec2962f --- /dev/null +++ b/packages/loot-core/migrations/1722804019000_create_dashboard_table.js @@ -0,0 +1,44 @@ +import { v4 as uuidv4 } from 'uuid'; + +/* eslint-disable rulesdir/typography */ +export default async function runMigration(db) { + db.transaction(() => { + db.execQuery(` + CREATE TABLE dashboard + (id TEXT PRIMARY KEY, + type TEXT, + width INTEGER, + height INTEGER, + x INTEGER, + y INTEGER, + meta TEXT, + tombstone INTEGER DEFAULT 0); + + INSERT INTO dashboard (id, type, width, height, x, y) + VALUES + ('${uuidv4()}','net-worth-card', 8, 2, 0, 0), + ('${uuidv4()}', 'cash-flow-card', 4, 2, 8, 0); + `); + + // Add custom reports to the dashboard + const reports = db.runQuery( + 'SELECT id FROM custom_reports WHERE tombstone = 0 ORDER BY name COLLATE NOCASE ASC', + [], + true, + ); + reports.forEach((report, id) => { + db.runQuery( + `INSERT INTO dashboard (id, type, width, height, x, y, meta) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + uuidv4(), + 'custom-report', + 4, + 2, + (id * 4) % 12, + 2 + Math.floor(id / 3) * 2, + JSON.stringify({ id: report.id }), + ], + ); + }); + }); +} diff --git a/packages/loot-core/src/client/data-hooks/dashboard.ts b/packages/loot-core/src/client/data-hooks/dashboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..457f813ac0667a1619dbd8d59ec59a53ed71b5a0 --- /dev/null +++ b/packages/loot-core/src/client/data-hooks/dashboard.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; + +import { q } from '../../shared/query'; +import { type Widget } from '../../types/models'; +import { useLiveQuery } from '../query-hooks'; + +export function useDashboard() { + const queryData = useLiveQuery<Widget[]>( + () => q('dashboard').select('*'), + [], + ); + + return useMemo( + () => ({ + isLoading: queryData === null, + data: queryData || [], + }), + [queryData], + ); +} diff --git a/packages/loot-core/src/client/reports.ts b/packages/loot-core/src/client/reports.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b34fc93d47f29497e162bf0a2a7148acb0d8996 --- /dev/null +++ b/packages/loot-core/src/client/reports.ts @@ -0,0 +1,87 @@ +import { + type AccountEntity, + type CategoryEntity, + type RuleConditionEntity, + type PayeeEntity, +} from '../types/models'; + +/** + * Checks if the given conditions have issues + * (i.e. non-existing category/payee/account being used). + */ +export function calculateHasWarning( + conditions: RuleConditionEntity[], + { + categories, + accounts, + payees, + }: { + categories: CategoryEntity[]; + accounts: AccountEntity[]; + payees: PayeeEntity[]; + }, +) { + const categoryIds = new Set(categories.map(({ id }) => id)); + const payeeIds = new Set(payees.map(({ id }) => id)); + const accountIds = new Set(accounts.map(({ id }) => id)); + + if (!conditions) { + return false; + } + + for (const cond of conditions) { + const { field, value, op } = cond; + const isMultiCondition = Array.isArray(value); + const isSupportedSingleCondition = ['is', 'isNot'].includes(op); + + // Regex and other more complicated operations are not supported + if (!isSupportedSingleCondition && !isMultiCondition) { + continue; + } + + // Empty value.. we can skip + if (!isMultiCondition && !value) { + continue; + } + + switch (field) { + case 'account': + if (isMultiCondition) { + if (value.find(val => !accountIds.has(val))) { + return true; + } + break; + } + + if (!accountIds.has(value)) { + return true; + } + break; + case 'payee': + if (isMultiCondition) { + if (value.find(val => !payeeIds.has(val))) { + return true; + } + break; + } + + if (!payeeIds.has(value)) { + return true; + } + break; + case 'category': + if (isMultiCondition) { + if (value.find(val => !categoryIds.has(val))) { + return true; + } + break; + } + + if (!categoryIds.has(value)) { + return true; + } + break; + } + } + return false; +} diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index 085912f6abce53184f818571c8d9bd5429c000e5..b35e2a25a5fac4ef9f766d333e6f3ea9387f3e58 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -170,6 +170,16 @@ export const schema = { goal: f('integer'), long_goal: f('integer'), }, + dashboard: { + id: f('id'), + type: f('string', { required: true }), + width: f('integer', { required: true }), + height: f('integer', { required: true }), + x: f('integer', { required: true }), + y: f('integer', { required: true }), + meta: f('json'), + tombstone: f('boolean'), + }, }; export const schemaConfig: SchemaConfig = { diff --git a/packages/loot-core/src/server/dashboard/app.ts b/packages/loot-core/src/server/dashboard/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..db6267d10655526970c87c663b5ff8fee8bac4fb --- /dev/null +++ b/packages/loot-core/src/server/dashboard/app.ts @@ -0,0 +1,251 @@ +import isMatch from 'lodash/isMatch'; + +import { captureException } from '../../platform/exceptions'; +import * as fs from '../../platform/server/fs'; +import { DEFAULT_DASHBOARD_STATE } from '../../shared/dashboard'; +import { q } from '../../shared/query'; +import { + type CustomReportEntity, + type ExportImportDashboard, + type ExportImportDashboardWidget, + type ExportImportCustomReportWidget, + type Widget, +} from '../../types/models'; +import { type EverythingButIdOptional } from '../../types/util'; +import { createApp } from '../app'; +import { runQuery as aqlQuery } from '../aql'; +import * as db from '../db'; +import { ValidationError } from '../errors'; +import { requiredFields } from '../models'; +import { mutator } from '../mutators'; +import { reportModel } from '../reports/app'; +import { batchMessages } from '../sync'; +import { undoable } from '../undo'; + +import { DashboardHandlers } from './types/handlers'; + +function isExportedCustomReportWidget( + widget: ExportImportDashboardWidget, +): widget is ExportImportCustomReportWidget { + return widget.type === 'custom-report'; +} + +const exportModel = { + validate(dashboard: ExportImportDashboard) { + requiredFields('Dashboard', dashboard, ['version', 'widgets']); + + if (!Array.isArray(dashboard.widgets)) { + throw new ValidationError( + 'Invalid dashboard.widgets data type: it must be an array of widgets.', + ); + } + + dashboard.widgets.forEach((widget, idx) => { + requiredFields(`Dashboard widget #${idx}`, widget, [ + 'type', + 'x', + 'y', + 'width', + 'height', + ...(isExportedCustomReportWidget(widget) ? ['meta' as const] : []), + ]); + + if (!Number.isInteger(widget.x)) { + throw new ValidationError( + `Invalid widget.${idx}.x data-type for value ${widget.x}.`, + ); + } + + if (!Number.isInteger(widget.y)) { + throw new ValidationError( + `Invalid widget.${idx}.y data-type for value ${widget.y}.`, + ); + } + + if (!Number.isInteger(widget.width)) { + throw new ValidationError( + `Invalid widget.${idx}.width data-type for value ${widget.width}.`, + ); + } + + if (!Number.isInteger(widget.height)) { + throw new ValidationError( + `Invalid widget.${idx}.height data-type for value ${widget.height}.`, + ); + } + + if ( + ![ + 'net-worth-card', + 'cash-flow-card', + 'spending-card', + 'custom-report', + ].includes(widget.type) + ) { + throw new ValidationError( + `Invalid widget.${idx}.type value ${widget.type}.`, + ); + } + + if (isExportedCustomReportWidget(widget)) { + reportModel.validate(widget.meta); + } + }); + }, +}; + +async function updateDashboard( + widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[], +) { + const { data: dbWidgets } = await aqlQuery( + q('dashboard') + .filter({ id: { $oneof: widgets.map(({ id }) => id) } }) + .select('*'), + ); + const dbWidgetMap = new Map( + (dbWidgets as Widget[]).map(widget => [widget.id, widget]), + ); + + await Promise.all( + widgets + // Perform an update query only if the widget actually has changes + .filter(widget => !isMatch(dbWidgetMap.get(widget.id) ?? {}, widget)) + .map(widget => db.update('dashboard', widget)), + ); +} + +async function updateDashboardWidget( + widget: EverythingButIdOptional<Omit<Widget, 'tombstone'>>, +) { + await db.update('dashboard', widget); +} + +async function resetDashboard() { + await batchMessages(async () => { + await Promise.all([ + // Delete all widgets + db.deleteAll('dashboard'), + + // Insert the default state + ...DEFAULT_DASHBOARD_STATE.map(widget => + db.insertWithSchema('dashboard', widget), + ), + ]); + }); +} + +async function addDashboardWidget( + widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> & + Partial<Pick<Widget, 'x' | 'y'>>, +) { + // If no x & y was provided - calculate it dynamically + // The new widget should be the very last one in the list of all widgets + if (!('x' in widget) && !('y' in widget)) { + const data = await db.first( + 'SELECT x, y, width, height FROM dashboard WHERE tombstone = 0 ORDER BY y DESC, x DESC', + ); + + if (!data) { + widget.x = 0; + widget.y = 0; + } else { + const xBoundaryCheck = data.x + data.width + widget.width; + widget.x = xBoundaryCheck > 12 ? 0 : data.x + data.width; + widget.y = data.y + (xBoundaryCheck > 12 ? data.height : 0); + } + } + + await db.insertWithSchema('dashboard', widget); +} + +async function removeDashboardWidget(widgetId: string) { + await db.delete_('dashboard', widgetId); +} + +async function importDashboard({ filepath }: { filepath: string }) { + try { + if (!(await fs.exists(filepath))) { + throw new Error(`File not found at the provided path: ${filepath}`); + } + + const content = await fs.readFile(filepath); + const parsedContent: ExportImportDashboard = JSON.parse(content); + + exportModel.validate(parsedContent); + + const customReportIds: CustomReportEntity[] = await db.all( + 'SELECT id from custom_reports', + ); + const customReportIdSet = new Set(customReportIds.map(({ id }) => id)); + + await batchMessages(async () => { + await Promise.all([ + // Delete all widgets + db.deleteAll('dashboard'), + + // Insert new widgets + ...parsedContent.widgets.map(widget => + db.insertWithSchema('dashboard', { + type: widget.type, + width: widget.width, + height: widget.height, + x: widget.x, + y: widget.y, + meta: isExportedCustomReportWidget(widget) + ? { id: widget.meta.id } + : null, + }), + ), + + // Insert new custom reports + ...parsedContent.widgets + .filter(isExportedCustomReportWidget) + .filter(({ meta }) => !customReportIdSet.has(meta.id)) + .map(({ meta }) => + db.insertWithSchema('custom_reports', reportModel.fromJS(meta)), + ), + + // Update existing reports + ...parsedContent.widgets + .filter(isExportedCustomReportWidget) + .filter(({ meta }) => customReportIdSet.has(meta.id)) + .map(({ meta }) => + db.updateWithSchema('custom_reports', { + // Replace `undefined` values with `null` + // (null clears the value in DB; undefined breaks the operation) + ...Object.fromEntries( + Object.entries(reportModel.fromJS(meta)).map(([key, value]) => [ + key, + value ?? null, + ]), + ), + tombstone: false, + }), + ), + ]); + }); + + return { status: 'ok' as const }; + } catch (err: unknown) { + if (err instanceof Error) { + err.message = 'Error importing file: ' + err.message; + captureException(err); + } + if (err instanceof SyntaxError) { + return { error: 'json-parse-error' as const }; + } + if (err instanceof ValidationError) { + return { error: 'validation-error' as const, message: err.message }; + } + return { error: 'internal-error' as const }; + } +} + +export const app = createApp<DashboardHandlers>(); + +app.method('dashboard-update', mutator(undoable(updateDashboard))); +app.method('dashboard-update-widget', mutator(undoable(updateDashboardWidget))); +app.method('dashboard-reset', mutator(undoable(resetDashboard))); +app.method('dashboard-add-widget', mutator(undoable(addDashboardWidget))); +app.method('dashboard-remove-widget', mutator(undoable(removeDashboardWidget))); +app.method('dashboard-import', mutator(undoable(importDashboard))); diff --git a/packages/loot-core/src/server/dashboard/types/handlers.d.ts b/packages/loot-core/src/server/dashboard/types/handlers.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d6e8319d38ceb00aa1978600780f2e3855c1e64 --- /dev/null +++ b/packages/loot-core/src/server/dashboard/types/handlers.d.ts @@ -0,0 +1,24 @@ +import { type Widget } from '../../../types/models'; +import { type EverythingButIdOptional } from '../../../types/util'; + +export interface DashboardHandlers { + 'dashboard-update': ( + widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[], + ) => Promise<void>; + 'dashboard-update-widget': ( + widget: EverythingButIdOptional<Omit<Widget, 'tombstone'>>, + ) => Promise<void>; + 'dashboard-reset': () => Promise<void>; + 'dashboard-add-widget': ( + widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> & + Partial<Pick<Widget, 'x' | 'y'>>, + ) => Promise<void>; + 'dashboard-remove-widget': (widgetId: string) => Promise<void>; + 'dashboard-import': (args: { + filepath: string; + }) => Promise< + | { status: 'ok' } + | { error: 'json-parse-error' | 'internal-error' } + | { error: 'validation-error'; message: string } + >; +} diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 46fa73423619747b02e144f3dbbf36f723cad5cf..9a7fd514b02e856ceaffe3c19fbfe83733c31f88 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -241,6 +241,13 @@ export async function delete_(table, id) { ]); } +export async function deleteAll(table: string) { + const rows: Array<{ id: string }> = await all(` + SELECT id FROM ${table} WHERE tombstone = 0 + `); + await Promise.all(rows.map(({ id }) => delete_(table, id))); +} + export async function selectWithSchema(table, sql, params) { const rows = await runQuery(sql, params, true); return rows @@ -540,7 +547,7 @@ export function getCommonPayees() { const threeMonthsAgo = '20240201'; const limit = 10; return all(` - SELECT p.id as id, p.name as name, p.favorite as favorite, + SELECT p.id as id, p.name as name, p.favorite as favorite, p.category as category, TRUE as common, NULL as transfer_acct, count(*) as c, max(t.date) as latest diff --git a/packages/loot-core/src/server/errors.ts b/packages/loot-core/src/server/errors.ts index d7228baab8e83a9c3336dec637c8312d7774fbff..09be2e9f62e216b04076ce5387f50f1e14e97139 100644 --- a/packages/loot-core/src/server/errors.ts +++ b/packages/loot-core/src/server/errors.ts @@ -51,6 +51,8 @@ export class SyncError extends Error { } } +export class ValidationError extends Error {} + export class TransactionError extends Error {} export class RuleError extends Error { diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 3dee2fcc7e6d89a37456e48fb835aade039fd4c9..26dd02b6282d8aca5a13a9a9f6053daac741b628 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -38,6 +38,7 @@ import { import { app as budgetApp } from './budget/app'; import * as budget from './budget/base'; import * as cloudStorage from './cloud-storage'; +import { app as dashboardApp } from './dashboard/app'; import * as db from './db'; import * as mappings from './db/mappings'; import * as encryption from './encryption'; @@ -2056,6 +2057,7 @@ app.handlers = handlers; app.combine( schedulesApp, budgetApp, + dashboardApp, notesApp, toolsApp, filtersApp, diff --git a/packages/loot-core/src/server/migrate/migrations.ts b/packages/loot-core/src/server/migrate/migrations.ts index f7a7f524e0b46498055c105485fc1fda6ebc01b9..4243b6a48357d34de72c0652e103f0e9b09a4d8d 100644 --- a/packages/loot-core/src/server/migrate/migrations.ts +++ b/packages/loot-core/src/server/migrate/migrations.ts @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; import m1632571489012 from '../../../migrations/1632571489012_remove_cache'; import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories'; +import m1722804019000 from '../../../migrations/1722804019000_create_dashboard_table'; import * as fs from '../../platform/server/fs'; import * as sqlite from '../../platform/server/sqlite'; @@ -15,6 +16,7 @@ let MIGRATIONS_DIR = fs.migrationsPath; const javascriptMigrations = { 1632571489012: m1632571489012, 1722717601000: m1722717601000, + 1722804019000: m1722804019000, }; export async function withMigrationsDir( diff --git a/packages/loot-core/src/server/models.ts b/packages/loot-core/src/server/models.ts index 03ddbeab06063e5058faf2f8f8ce5abe6b1f1314..772ead3f154b3d90db1bc9d720a37c5acc22e47c 100644 --- a/packages/loot-core/src/server/models.ts +++ b/packages/loot-core/src/server/models.ts @@ -5,6 +5,8 @@ import { PayeeEntity, } from '../types/models'; +import { ValidationError } from './errors'; + export function requiredFields<T extends object, K extends keyof T>( name: string, row: T, @@ -14,11 +16,11 @@ export function requiredFields<T extends object, K extends keyof T>( fields.forEach(field => { if (update) { if (row.hasOwnProperty(field) && row[field] == null) { - throw new Error(`${name} is missing field ${String(field)}`); + throw new ValidationError(`${name} is missing field ${String(field)}`); } } else { if (!row.hasOwnProperty(field) || row[field] == null) { - throw new Error(`${name} is missing field ${String(field)}`); + throw new ValidationError(`${name} is missing field ${String(field)}`); } } }); diff --git a/packages/loot-core/src/server/reports/app.ts b/packages/loot-core/src/server/reports/app.ts index b56405ed72879b1b7c5d689d4949be61e63db7e3..faa86b1bae07deff08168a811fb85772c5c30c34 100644 --- a/packages/loot-core/src/server/reports/app.ts +++ b/packages/loot-core/src/server/reports/app.ts @@ -6,19 +6,25 @@ import { } from '../../types/models'; import { createApp } from '../app'; import * as db from '../db'; +import { ValidationError } from '../errors'; import { requiredFields } from '../models'; import { mutator } from '../mutators'; import { undoable } from '../undo'; import { ReportsHandlers } from './types/handlers'; -const reportModel = { - validate(report: CustomReportEntity, { update }: { update?: boolean } = {}) { +export const reportModel = { + validate( + report: Omit<CustomReportEntity, 'tombstone'>, + { update }: { update?: boolean } = {}, + ) { requiredFields('Report', report, ['conditionsOp'], update); if (!update || 'conditionsOp' in report) { if (!['and', 'or'].includes(report.conditionsOp)) { - throw new Error('Invalid filter conditionsOp: ' + report.conditionsOp); + throw new ValidationError( + 'Invalid filter conditionsOp: ' + report.conditionsOp, + ); } } diff --git a/packages/loot-core/src/shared/dashboard.ts b/packages/loot-core/src/shared/dashboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..1479d9b3b4bd5f990ad5e97fc49bb54338cd2b1c --- /dev/null +++ b/packages/loot-core/src/shared/dashboard.ts @@ -0,0 +1,20 @@ +import { type NewWidget } from '../types/models'; + +export const DEFAULT_DASHBOARD_STATE: NewWidget[] = [ + { + type: 'net-worth-card', + width: 8, + height: 2, + x: 0, + y: 0, + meta: null, + }, + { + type: 'cash-flow-card', + width: 4, + height: 2, + x: 8, + y: 0, + meta: null, + }, +]; diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index 5eb8f281af6c24547e72d0b67ab750b2273d7c19..39d69edbd19840436cc966203573ca03e1f49b7f 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -1,4 +1,5 @@ import type { BudgetHandlers } from '../server/budget/types/handlers'; +import type { DashboardHandlers } from '../server/dashboard/types/handlers'; import type { FiltersHandlers } from '../server/filters/types/handlers'; import type { NotesHandlers } from '../server/notes/types/handlers'; import type { ReportsHandlers } from '../server/reports/types/handlers'; @@ -13,6 +14,7 @@ export interface Handlers extends ServerHandlers, ApiHandlers, BudgetHandlers, + DashboardHandlers, FiltersHandlers, NotesHandlers, ReportsHandlers, diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..87e809a127a8e7f40f12ef6944d0686aba095aff --- /dev/null +++ b/packages/loot-core/src/types/models/dashboard.d.ts @@ -0,0 +1,47 @@ +import { type CustomReportEntity } from './reports'; + +type AbstractWidget< + T extends string, + Meta extends Record<string, unknown> = null, +> = { + id: string; + type: T; + x: number; + y: number; + width: number; + height: number; + meta: Meta; + tombstone: boolean; +}; + +type NetWorthWidget = AbstractWidget<'net-worth-card'>; +type CashFlowWidget = AbstractWidget<'cash-flow-card'>; +type SpendingWidget = AbstractWidget<'spending-card'>; +export type CustomReportWidget = AbstractWidget< + 'custom-report', + { id: string } +>; + +type SpecializedWidget = NetWorthWidget | CashFlowWidget | SpendingWidget; +export type Widget = SpecializedWidget | CustomReportWidget; +export type NewWidget = Omit<Widget, 'id' | 'tombstone'>; + +// Exported/imported (json) widget definition +export type ExportImportCustomReportWidget = Omit< + CustomReportWidget, + 'id' | 'meta' | 'tombstone' +> & { + meta: Omit<CustomReportEntity, 'tombstone'>; +}; +export type ExportImportDashboardWidget = Omit< + ExportImportCustomReportWidget | SpecializedWidget, + 'tombstone' +>; + +export type ExportImportDashboard = { + // Dashboard exports can be versioned; currently we support + // only a single version, but lets account for multiple + // future versions + version: 1; + widgets: ExportImportDashboardWidget[]; +}; diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts index 1e08cb06a26b34cedf3f0555d52c9fb7b657f34d..3e64570feb870bfd8b4e635846be6d639979de6f 100644 --- a/packages/loot-core/src/types/models/index.d.ts +++ b/packages/loot-core/src/types/models/index.d.ts @@ -1,6 +1,7 @@ export type * from './account'; export type * from './category'; export type * from './category-group'; +export type * from './dashboard'; export type * from './gocardless'; export type * from './simplefin'; export type * from './note'; diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index a348eca04b405ed3f1da0e22dfbac93dac0ddb80..c1d7054ea481f70665ee1edaa01bb2aa3c15801b 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -3,6 +3,7 @@ import { type numberFormats } from '../shared/util'; import { spendingReportTimeType } from './models/reports'; export type FeatureFlag = + | 'dashboards' | 'reportBudget' | 'goalTemplatesEnabled' | 'spendingReport' diff --git a/packages/loot-core/src/types/util.d.ts b/packages/loot-core/src/types/util.d.ts index f478d90061fd1ff1b75e7307f5197d99b2f38f43..3a7d6881c27475793158452f407e3daf8513bb9d 100644 --- a/packages/loot-core/src/types/util.d.ts +++ b/packages/loot-core/src/types/util.d.ts @@ -3,3 +3,7 @@ export type EmptyObject = Record<never, never>; export type StripNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K]; }; + +export type EverythingButIdOptional<T> = { id: T['id'] } & Partial< + Omit<T, 'id'> +>; diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts index ddb0da659cf326f52b4744ab2233180ab16f98d8..7f9173c6ae7e76a99a8478d1839f286a8bd257c5 100644 --- a/packages/loot-core/typings/window.d.ts +++ b/packages/loot-core/typings/window.d.ts @@ -7,7 +7,7 @@ declare global { ACTUAL_VERSION: string; openURLInBrowser: (url: string) => void; saveFile: ( - contents: Buffer, + contents: string | Buffer, filename: string, dialogTitle: string, ) => void; diff --git a/upcoming-release-notes/3044.md b/upcoming-release-notes/3044.md index 63158f9c5de9f090a51260460f74edb090189c27..bf910bfb4b7753b98d8b96f8ca2054ea6cb0d891 100644 --- a/upcoming-release-notes/3044.md +++ b/upcoming-release-notes/3044.md @@ -1,6 +1,6 @@ --- category: Bugfix -authors: [youngcw,wdpk] +authors: [youngcw, wdpk] --- -Fix decimal comma parsing for ofx files \ No newline at end of file +Fix decimal comma parsing for ofx files diff --git a/upcoming-release-notes/3140.md b/upcoming-release-notes/3140.md index 0802097b93cf8ba8a079b6899cf2ec19e9ee4ab5..badd3c96ae201d634c60e4750327d0e1d91c2466 100644 --- a/upcoming-release-notes/3140.md +++ b/upcoming-release-notes/3140.md @@ -3,4 +3,4 @@ category: Enhancements authors: [rodriguestiago0] --- -Add `reset-hold` and `hold-for-next-month` methods to the API \ No newline at end of file +Add `reset-hold` and `hold-for-next-month` methods to the API diff --git a/upcoming-release-notes/3183.md b/upcoming-release-notes/3183.md index 852936ebd44a4eb2624d8e8cbd3aba75f263dd4d..7d783c71e904b61e243a1d785f79c163e3371a04 100644 --- a/upcoming-release-notes/3183.md +++ b/upcoming-release-notes/3183.md @@ -1,6 +1,6 @@ --- category: Maintenance -authors: [ ACWalker ] +authors: [ACWalker] --- Add unit tests for the existing goal template types. diff --git a/upcoming-release-notes/3220.md b/upcoming-release-notes/3220.md index 4db0be806d4604087560dfad50ecb2523ddb0b6c..2baac8d13986ad0d3e7213c07ddc13c0ce30c36e 100644 --- a/upcoming-release-notes/3220.md +++ b/upcoming-release-notes/3220.md @@ -4,4 +4,3 @@ authors: [MikesGlitch] --- Fix electron builds throwing "We had an unknown problem opening file" - diff --git a/upcoming-release-notes/3221.md b/upcoming-release-notes/3221.md index 13d9f0bb790ceb1031b9ae4c56f4d5c9e1dcf359..6328751b22ec44ce9ebd7a429a782c9b34666e58 100644 --- a/upcoming-release-notes/3221.md +++ b/upcoming-release-notes/3221.md @@ -1,6 +1,6 @@ --- category: Maintenance -authors: [ ACWalker ] +authors: [ACWalker] --- Extract, refactor and test note handling logic from `goaltemplates.ts` file. diff --git a/upcoming-release-notes/3231.md b/upcoming-release-notes/3231.md new file mode 100644 index 0000000000000000000000000000000000000000..38245cc9058eed95def0e2a660e81ffba5237e8d --- /dev/null +++ b/upcoming-release-notes/3231.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [MatissJanis] +--- + +Customizable dashboard for reports page - drag-able and resizable widgets. diff --git a/yarn.lock b/yarn.lock index 947d418bec5d42b2739edd73ede8df3303bdf4da..7e0f725013298e7f857902a2f169b477d611f111 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,6 +77,7 @@ __metadata: "@types/promise-retry": "npm:^1.1.6" "@types/react": "npm:^18.2.0" "@types/react-dom": "npm:^18.2.1" + "@types/react-grid-layout": "npm:^1" "@types/react-modal": "npm:^3.16.0" "@types/react-redux": "npm:^7.1.25" "@types/uuid": "npm:^9.0.2" @@ -111,6 +112,7 @@ __metadata: react-dnd-html5-backend: "npm:^16.0.1" react-dom: "npm:18.2.0" react-error-boundary: "npm:^4.0.12" + react-grid-layout: "npm:^1.4.4" react-hotkeys-hook: "npm:^4.5.0" react-i18next: "npm:^14.1.2" react-markdown: "npm:^8.0.7" @@ -5576,6 +5578,15 @@ __metadata: languageName: node linkType: hard +"@types/react-grid-layout@npm:^1": + version: 1.3.5 + resolution: "@types/react-grid-layout@npm:1.3.5" + dependencies: + "@types/react": "npm:*" + checksum: 10/21599054dfa977ed8445b1ab3198a842531cb36bd620564c3f8a469688cbea051149eb644a99b2f8f6d02f8d9909a6145668f385781b5647601e9663de216946 + languageName: node + linkType: hard + "@types/react-modal@npm:^3.16.0": version: 3.16.0 resolution: "@types/react-modal@npm:3.16.0" @@ -7734,6 +7745,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^1.1.1": + version: 1.2.1 + resolution: "clsx@npm:1.2.1" + checksum: 10/5ded6f61f15f1fa0350e691ccec43a28b12fb8e64c8e94715f2a937bc3722d4c3ed41d6e945c971fc4dcc2a7213a43323beaf2e1c28654af63ba70c9968a8643 + languageName: node + linkType: hard + "clsx@npm:^2.0.0": version: 2.1.0 resolution: "clsx@npm:2.1.0" @@ -10099,6 +10117,13 @@ __metadata: languageName: node linkType: hard +"fast-equals@npm:^4.0.3": + version: 4.0.3 + resolution: "fast-equals@npm:4.0.3" + checksum: 10/04c1ff47b79923314e9b63ec6c81beeaa5e3b9588ec230ee6aff7ece725ff834a72abf627055055127bd0f53ae8a92cc04c3a6e187783fd932dbef743f9b13bf + languageName: node + linkType: hard + "fast-equals@npm:^5.0.0": version: 5.0.1 resolution: "fast-equals@npm:5.0.1" @@ -15743,7 +15768,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:15.x, prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -16020,6 +16045,19 @@ __metadata: languageName: node linkType: hard +"react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5": + version: 4.4.6 + resolution: "react-draggable@npm:4.4.6" + dependencies: + clsx: "npm:^1.1.1" + prop-types: "npm:^15.8.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10/51b9ac7f913797fc1cebc30ae383f346883033c45eb91e9b0b92e9ebd224bb1545b4ae2391825b649b798cc711a38351a5f41be24d949c64c6703ebc24eba661 + languageName: node + linkType: hard + "react-error-boundary@npm:^4.0.12": version: 4.0.12 resolution: "react-error-boundary@npm:4.0.12" @@ -16031,6 +16069,23 @@ __metadata: languageName: node linkType: hard +"react-grid-layout@npm:^1.4.4": + version: 1.4.4 + resolution: "react-grid-layout@npm:1.4.4" + dependencies: + clsx: "npm:^2.0.0" + fast-equals: "npm:^4.0.3" + prop-types: "npm:^15.8.1" + react-draggable: "npm:^4.4.5" + react-resizable: "npm:^3.0.5" + resize-observer-polyfill: "npm:^1.5.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10/43c9bc7e8ce5a902720ebe559b1551724615c617391b040a0a275597748e1927b293c5ddcb73053b1925909e1c293948b4969a35799d541bf9d8886e07ac0085 + languageName: node + linkType: hard + "react-hotkeys-hook@npm:^4.5.0": version: 4.5.0 resolution: "react-hotkeys-hook@npm:4.5.0" @@ -16149,6 +16204,18 @@ __metadata: languageName: node linkType: hard +"react-resizable@npm:^3.0.5": + version: 3.0.5 + resolution: "react-resizable@npm:3.0.5" + dependencies: + prop-types: "npm:15.x" + react-draggable: "npm:^4.0.3" + peerDependencies: + react: ">= 16.3" + checksum: 10/745fad6ac827857b3a80d1d648b8d6723aa72fc17d5410a01707073f3d37b4adf6e0354dfe3cc33dee34d6e546a3fbd5603ef73e385dfc5218a425a39bf96275 + languageName: node + linkType: hard + "react-router-dom@npm:6.21.3": version: 6.21.3 resolution: "react-router-dom@npm:6.21.3" @@ -16619,6 +16686,13 @@ __metadata: languageName: node linkType: hard +"resize-observer-polyfill@npm:^1.5.1": + version: 1.5.1 + resolution: "resize-observer-polyfill@npm:1.5.1" + checksum: 10/e10ee50cd6cf558001de5c6fb03fee15debd011c2f694564b71f81742eef03fb30d6c2596d1d5bf946d9991cb692fcef529b7bd2e4057041377ecc9636c753ce + languageName: node + linkType: hard + "resolve-alpn@npm:^1.0.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1"