Add Report pages (#6411)

* Adding multiple report pages

* Adding release notes

* Updating release note number

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

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

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

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

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

* refactor move widget to use add and remove

* Move/Copy modal

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

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

* some bug/clarity fixes

* better type discipline, dashboard_pages schema, PR review fixes

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

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

* Fix translation interpolation

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

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

* renaming for dashboard pages and error handling

* abstracting out `isWidgetType` function

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

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

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

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

* fix some deletion navigation issues and idioms

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6411

* Translate 'modified' status in SaveReport component

* [autofix.ci] apply automated fixes

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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