mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 10:14:53 -05:00
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:
@@ -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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user