✨ (dashboards) release as first party feature (#3856)
@@ -22,8 +22,9 @@ export class ReportsPage {
|
||||
|
||||
async goToCustomReportPage() {
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Create new custom report' })
|
||||
.getByRole('button', { name: 'Add new widget' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'New custom report' }).click();
|
||||
return new CustomReportPage(this.page);
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
@@ -7,7 +7,6 @@ import {
|
||||
type TimeFrame,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Select } from '../common/Select';
|
||||
import { SpaceBetween } from '../common/SpaceBetween';
|
||||
@@ -62,7 +61,6 @@ export function Header({
|
||||
children,
|
||||
}: HeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
return (
|
||||
@@ -80,7 +78,7 @@ export function Header({
|
||||
}}
|
||||
>
|
||||
<SpaceBetween gap={isNarrowWidth ? 5 : undefined}>
|
||||
{isDashboardsFeatureEnabled && mode && (
|
||||
{mode && (
|
||||
<Button
|
||||
variant={mode === 'static' ? 'normal' : 'primary'}
|
||||
onPress={() => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { breakpoints } from '../../tokens';
|
||||
import { Button } from '../common/Button2';
|
||||
@@ -79,28 +78,16 @@ export function Overview() {
|
||||
const location = useLocation();
|
||||
sessionStorage.setItem('url', location.pathname);
|
||||
|
||||
const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
|
||||
|
||||
const baseLayout = widgets
|
||||
.map(widget => ({
|
||||
i: widget.id,
|
||||
w: widget.width,
|
||||
h: widget.height,
|
||||
minW:
|
||||
isCustomReportWidget(widget) || widget.type === 'markdown-card' ? 2 : 3,
|
||||
minH:
|
||||
isCustomReportWidget(widget) || widget.type === 'markdown-card' ? 1 : 2,
|
||||
...widget,
|
||||
}))
|
||||
.filter(item => {
|
||||
if (isDashboardsFeatureEnabled) {
|
||||
return true;
|
||||
}
|
||||
if (item.type === 'custom-report' && !customReportMap.has(item.meta.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const baseLayout = widgets.map(widget => ({
|
||||
i: widget.id,
|
||||
w: widget.width,
|
||||
h: widget.height,
|
||||
minW:
|
||||
isCustomReportWidget(widget) || widget.type === 'markdown-card' ? 2 : 3,
|
||||
minH:
|
||||
isCustomReportWidget(widget) || widget.type === 'markdown-card' ? 1 : 2,
|
||||
...widget,
|
||||
}));
|
||||
|
||||
const layout = baseLayout;
|
||||
|
||||
@@ -332,86 +319,82 @@ export function Overview() {
|
||||
>
|
||||
{currentBreakpoint === 'desktop' && (
|
||||
<>
|
||||
{isDashboardsFeatureEnabled && (
|
||||
<>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="primary"
|
||||
isDisabled={isImporting}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
>
|
||||
<Trans>Add new widget</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="primary"
|
||||
isDisabled={isImporting}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
>
|
||||
<Trans>Add new widget</Trans>
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'custom-report') {
|
||||
navigate('/reports/custom');
|
||||
return;
|
||||
}
|
||||
<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<CustomReportWidget>('custom-report', {
|
||||
id: reportId,
|
||||
});
|
||||
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<CustomReportWidget>('custom-report', {
|
||||
id: reportId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (item === 'markdown-card') {
|
||||
onAddWidget<MarkdownWidget>(item, {
|
||||
content: t(
|
||||
'### Text Widget\n\nEdit this widget to change the **markdown** content.',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (item === 'markdown-card') {
|
||||
onAddWidget<MarkdownWidget>(item, {
|
||||
content: t(
|
||||
'### Text Widget\n\nEdit this widget to change the **markdown** content.',
|
||||
),
|
||||
});
|
||||
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'),
|
||||
},
|
||||
{
|
||||
name: 'spending-card' as const,
|
||||
text: t('Spending analysis'),
|
||||
},
|
||||
{
|
||||
name: 'markdown-card' as const,
|
||||
text: t('Text widget'),
|
||||
},
|
||||
{
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
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'),
|
||||
},
|
||||
{
|
||||
name: 'spending-card' as const,
|
||||
text: t('Spending analysis'),
|
||||
},
|
||||
{
|
||||
name: 'markdown-card' as const,
|
||||
text: t('Text widget'),
|
||||
},
|
||||
{
|
||||
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>
|
||||
|
||||
{isEditing ? (
|
||||
<Button
|
||||
@@ -420,71 +403,59 @@ export function Overview() {
|
||||
>
|
||||
<Trans>Finish editing dashboard</Trans>
|
||||
</Button>
|
||||
) : isDashboardsFeatureEnabled ? (
|
||||
) : (
|
||||
<Button
|
||||
isDisabled={isImporting}
|
||||
onPress={() => setIsEditing(true)}
|
||||
>
|
||||
<Trans>Edit dashboard</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={isImporting}
|
||||
onPress={() => navigate('/reports/custom')}
|
||||
>
|
||||
<Trans>Create new custom report</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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Bar, BarChart, LabelList, ResponsiveContainer } from 'recharts';
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
import { type CashFlowWidget } from 'loot-core/src/types/models';
|
||||
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { theme } from '../../../style';
|
||||
import { View } from '../../common/View';
|
||||
import { PrivacyFilter } from '../../PrivacyFilter';
|
||||
@@ -98,7 +97,6 @@ export function CashFlowCard({
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
}: CashFlowCardProps) {
|
||||
const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame, defaultTimeFrame);
|
||||
@@ -121,11 +119,7 @@ export function CashFlowCard({
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
to={
|
||||
isDashboardsFeatureEnabled
|
||||
? `/reports/cash-flow/${widgetId}`
|
||||
: '/reports/cash-flow'
|
||||
}
|
||||
to={`/reports/cash-flow/${widgetId}`}
|
||||
menuItems={[
|
||||
{
|
||||
name: 'rename',
|
||||
|
||||
@@ -11,7 +11,6 @@ import { type CustomReportEntity } from 'loot-core/types/models/reports';
|
||||
|
||||
import { useAccounts } from '../../../hooks/useAccounts';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { usePayees } from '../../../hooks/usePayees';
|
||||
import { useSyncedPref } from '../../../hooks/useSyncedPref';
|
||||
import { SvgExclamationSolid } from '../../../icons/v1';
|
||||
@@ -38,15 +37,9 @@ export function CustomReportListCards({
|
||||
report,
|
||||
onRemove,
|
||||
}: CustomReportListCardsProps) {
|
||||
const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
|
||||
|
||||
// It's possible for a dashboard to reference a non-existing
|
||||
// custom report
|
||||
if (!report) {
|
||||
if (!isDashboardsFeatureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MissingReportCard isEditing={isEditing} onRemove={onRemove}>
|
||||
{t('This custom report has been deleted.')}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type NetWorthWidget,
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { styles } from '../../../style';
|
||||
import { Block } from '../../common/Block';
|
||||
import { View } from '../../common/View';
|
||||
@@ -40,7 +39,6 @@ export function NetWorthCard({
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
}: NetWorthCardProps) {
|
||||
const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
@@ -67,11 +65,7 @@ export function NetWorthCard({
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
to={
|
||||
isDashboardsFeatureEnabled
|
||||
? `/reports/net-worth/${widgetId}`
|
||||
: '/reports/net-worth'
|
||||
}
|
||||
to={`/reports/net-worth/${widgetId}`}
|
||||
menuItems={[
|
||||
{
|
||||
name: 'rename',
|
||||
|
||||
@@ -13,7 +13,6 @@ import { amountToCurrency } from 'loot-core/src/shared/util';
|
||||
import { type SpendingWidget } from 'loot-core/types/models';
|
||||
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
|
||||
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { useFilters } from '../../../hooks/useFilters';
|
||||
import { useNavigate } from '../../../hooks/useNavigate';
|
||||
import { theme, styles } from '../../../style';
|
||||
@@ -61,7 +60,6 @@ type SpendingInternalProps = {
|
||||
};
|
||||
|
||||
function SpendingInternal({ widget }: SpendingInternalProps) {
|
||||
const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -237,26 +235,22 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
|
||||
>
|
||||
{!isNarrowWidth && (
|
||||
<SpaceBetween gap={0}>
|
||||
{isDashboardsFeatureEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant={isLive ? 'primary' : 'normal'}
|
||||
onPress={() => setIsLive(state => !state)}
|
||||
>
|
||||
{isLive ? t('Live') : t('Static')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isLive ? 'primary' : 'normal'}
|
||||
onPress={() => setIsLive(state => !state)}
|
||||
>
|
||||
{isLive ? t('Live') : t('Static')}
|
||||
</Button>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: 1,
|
||||
height: 28,
|
||||
backgroundColor: theme.pillBorderDark,
|
||||
marginRight: 10,
|
||||
marginLeft: 10,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
width: 1,
|
||||
height: 28,
|
||||
backgroundColor: theme.pillBorderDark,
|
||||
marginRight: 10,
|
||||
marginLeft: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
<SpaceBetween gap={5}>
|
||||
<Text>
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { amountToCurrency } from 'loot-core/src/shared/util';
|
||||
import { type SpendingWidget } from 'loot-core/src/types/models';
|
||||
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { styles } from '../../../style/styles';
|
||||
import { theme } from '../../../style/theme';
|
||||
import { Block } from '../../common/Block';
|
||||
@@ -35,7 +34,6 @@ export function SpendingCard({
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
}: SpendingCardProps) {
|
||||
const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
|
||||
@@ -71,11 +69,7 @@ export function SpendingCard({
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
to={
|
||||
isDashboardsFeatureEnabled
|
||||
? `/reports/spending/${widgetId}`
|
||||
: '/reports/spending'
|
||||
}
|
||||
to={`/reports/spending/${widgetId}`}
|
||||
menuItems={[
|
||||
{
|
||||
name: 'rename',
|
||||
|
||||
@@ -78,12 +78,6 @@ export function ExperimentalFeatures() {
|
||||
<FeatureToggle flag="goalTemplatesEnabled">
|
||||
<Trans>Goal templates</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle
|
||||
flag="dashboards"
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/3282"
|
||||
>
|
||||
<Trans>Customizable reports page (dashboards)</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle
|
||||
flag="actionTemplating"
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/3606"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSyncedPref } from './useSyncedPref';
|
||||
|
||||
const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
goalTemplatesEnabled: false,
|
||||
dashboards: false,
|
||||
actionTemplating: false,
|
||||
upcomingLengthAdjustment: false,
|
||||
contextMenus: false,
|
||||
|
||||
1
packages/loot-core/src/types/prefs.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
export type FeatureFlag =
|
||||
| 'dashboards'
|
||||
| 'goalTemplatesEnabled'
|
||||
| 'actionTemplating'
|
||||
| 'upcomingLengthAdjustment'
|
||||
|
||||
6
upcoming-release-notes/3856.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Dashboards: release as first party feature.
|
||||