From c66d6e00f5c91d4a7dc03fd69a6299bec11463d8 Mon Sep 17 00:00:00 2001 From: Neil <55785687+carkom@users.noreply.github.com> Date: Sat, 20 Jan 2024 10:22:28 +0000 Subject: [PATCH] Custom Reports - add schema (#2246) * Add schema work * notes * merge fixes * add to handlers * notes update * Update packages/loot-core/src/server/reports/app.ts Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv> * review changes * type updates --------- Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv> --- .../src/components/common/AnchorLink.tsx | 5 + .../src/components/reports/Overview.jsx | 5 +- .../src/components/reports/ReportCard.tsx | 26 ++++- .../src/components/reports/ReportOptions.ts | 31 +++++- .../reports/reports/CustomReport.jsx | 37 ++++--- .../reports/reports/CustomReportCard.jsx | 4 +- .../src/client/data-hooks/reports.ts | 48 +++++++++ .../loot-core/src/server/aql/schema/index.ts | 20 ++++ packages/loot-core/src/server/main.ts | 11 +- packages/loot-core/src/server/reports/app.ts | 100 ++++++++++++++++++ .../src/server/reports/types/handlers.ts | 9 ++ packages/loot-core/src/types/handlers.d.ts | 2 + .../loot-core/src/types/models/reports.d.ts | 11 +- upcoming-release-notes/2246.md | 6 ++ 14 files changed, 284 insertions(+), 31 deletions(-) create mode 100644 packages/loot-core/src/client/data-hooks/reports.ts create mode 100644 packages/loot-core/src/server/reports/app.ts create mode 100644 packages/loot-core/src/server/reports/types/handlers.ts create mode 100644 upcoming-release-notes/2246.md diff --git a/packages/desktop-client/src/components/common/AnchorLink.tsx b/packages/desktop-client/src/components/common/AnchorLink.tsx index 5defecba6..ed596b00d 100644 --- a/packages/desktop-client/src/components/common/AnchorLink.tsx +++ b/packages/desktop-client/src/components/common/AnchorLink.tsx @@ -3,6 +3,8 @@ import { NavLink, useMatch } from 'react-router-dom'; import { css } from 'glamor'; +import { type CustomReportEntity } from 'loot-core/src/types/models'; + import { type CSSProperties, styles } from '../../style'; type AnchorLinkProps = { @@ -10,6 +12,7 @@ type AnchorLinkProps = { style?: CSSProperties; activeStyle?: CSSProperties; children?: ReactNode; + report?: CustomReportEntity; }; export function AnchorLink({ @@ -17,12 +20,14 @@ export function AnchorLink({ style, activeStyle, children, + report, }: AnchorLinkProps) { const match = useMatch({ path: to }); return ( <NavLink to={to} + state={report ? { report } : {}} className={`${css([ styles.smallText, style, diff --git a/packages/desktop-client/src/components/reports/Overview.jsx b/packages/desktop-client/src/components/reports/Overview.jsx index 93fd2f26b..16a6b1e32 100644 --- a/packages/desktop-client/src/components/reports/Overview.jsx +++ b/packages/desktop-client/src/components/reports/Overview.jsx @@ -1,6 +1,8 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import { useReports } from 'loot-core/src/client/data-hooks/reports'; + import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { styles } from '../../style'; import { View } from '../common/View'; @@ -12,6 +14,7 @@ import { NetWorthCard } from './reports/NetWorthCard'; import { SankeyCard } from './reports/SankeyCard'; export function Overview() { + const customReports = useReports(); const categorySpendingReportFeatureFlag = useFeatureFlag( 'categorySpendingReport', ); @@ -45,7 +48,7 @@ export function Overview() { {categorySpendingReportFeatureFlag && <CategorySpendingCard />} {sankeyFeatureFlag && <SankeyCard />} {customReportsFeatureFlag ? ( - <CustomReportCard /> + <CustomReportCard reports={customReports} /> ) : ( <div style={{ flex: 1 }} /> )} diff --git a/packages/desktop-client/src/components/reports/ReportCard.tsx b/packages/desktop-client/src/components/reports/ReportCard.tsx index db8b6b242..ad9fe83d1 100644 --- a/packages/desktop-client/src/components/reports/ReportCard.tsx +++ b/packages/desktop-client/src/components/reports/ReportCard.tsx @@ -1,11 +1,26 @@ -// @ts-strict-ignore -import React from 'react'; +import React, { type ReactNode } from 'react'; -import { theme } from '../../style'; +import { type CustomReportEntity } from 'loot-core/src/types/models'; + +import { type CSSProperties, theme } from '../../style'; import { AnchorLink } from '../common/AnchorLink'; import { View } from '../common/View'; -export function ReportCard({ flex, to, style, children }) { +type ReportCardProps = { + to: string; + report: CustomReportEntity; + children: ReactNode; + flex?: string; + style?: CSSProperties; +}; + +export function ReportCard({ + to, + report, + children, + flex, + style, +}: ReportCardProps) { const containerProps = { flex, margin: 15 }; const content = ( @@ -34,7 +49,8 @@ export function ReportCard({ flex, to, style, children }) { return ( <AnchorLink to={to} - style={{ textDecoration: 'none', flex, ...containerProps }} + report={report} + style={{ textDecoration: 'none', ...containerProps }} > {content} </AnchorLink> diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts index f32dbe5e1..7cc13b86e 100644 --- a/packages/desktop-client/src/components/reports/ReportOptions.ts +++ b/packages/desktop-client/src/components/reports/ReportOptions.ts @@ -1,11 +1,32 @@ -// @ts-strict-ignore +import * as monthUtils from 'loot-core/src/shared/months'; import { + type CustomReportEntity, type AccountEntity, type CategoryEntity, type CategoryGroupEntity, type PayeeEntity, } from 'loot-core/src/types/models'; +const startDate = monthUtils.subMonths(monthUtils.currentMonth(), 5); +const endDate = monthUtils.currentMonth(); + +export const defaultState: CustomReportEntity = { + id: undefined, + mode: 'total', + groupBy: 'Category', + balanceType: 'Payment', + showEmpty: false, + showOffBudgetHidden: false, + showUncategorized: false, + graphType: 'BarGraph', + startDate, + endDate, + selectedCategories: null, + isDateStatic: false, + conditionsOp: 'and', + name: 'Default', +}; + const balanceTypeOptions = [ { description: 'Payment', format: 'totalDebts' as const }, { description: 'Deposit', format: 'totalAssets' as const }, @@ -83,7 +104,7 @@ export type UncategorizedEntity = Pick< const uncategorizedCategory: UncategorizedEntity = { name: 'Uncategorized', - id: null, + id: undefined, uncategorized_id: '1', hidden: false, is_off_budget: false, @@ -92,7 +113,7 @@ const uncategorizedCategory: UncategorizedEntity = { }; const transferCategory: UncategorizedEntity = { name: 'Transfers', - id: null, + id: undefined, uncategorized_id: '2', hidden: false, is_off_budget: false, @@ -101,7 +122,7 @@ const transferCategory: UncategorizedEntity = { }; const offBudgetCategory: UncategorizedEntity = { name: 'Off Budget', - id: null, + id: undefined, uncategorized_id: '3', hidden: false, is_off_budget: true, @@ -118,7 +139,7 @@ type UncategorizedGroupEntity = Pick< const uncategorizedGroup: UncategorizedGroupEntity = { name: 'Uncategorized & Off Budget', - id: null, + id: undefined, hidden: false, categories: [uncategorizedCategory, transferCategory, offBudgetCategory], }; diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx index 476057dca..009a66e88 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import * as d from 'date-fns'; @@ -23,7 +24,7 @@ import { ChooseGraph } from '../ChooseGraph'; import { Header } from '../Header'; import { LoadingIndicator } from '../LoadingIndicator'; import { ReportLegend } from '../ReportLegend'; -import { ReportOptions } from '../ReportOptions'; +import { ReportOptions, defaultState } from '../ReportOptions'; import { ReportSidebar } from '../ReportSidebar'; import { ReportSummary } from '../ReportSummary'; import { ReportTopbar } from '../ReportTopbar'; @@ -52,26 +53,34 @@ export function CustomReport() { onCondOpChange, } = useFilters(); - const [selectedCategories, setSelectedCategories] = useState(null); + const location = useLocation(); + const loadReport = location.state.report ?? defaultState; + const [allMonths, setAllMonths] = useState(null); const [typeDisabled, setTypeDisabled] = useState(['Net']); - const [startDate, setStartDate] = useState( - monthUtils.subMonths(monthUtils.currentMonth(), 5), + + const [selectedCategories, setSelectedCategories] = useState( + loadReport.selectedCategories, + ); + const [startDate, setStartDate] = useState(loadReport.startDate); + const [endDate, setEndDate] = useState(loadReport.endDate); + const [mode, setMode] = useState(loadReport.mode); + const [isDateStatic, setIsDateStatic] = useState(loadReport.isDateStatic); + const [groupBy, setGroupBy] = useState(loadReport.groupBy); + const [balanceType, setBalanceType] = useState(loadReport.balanceType); + const [showEmpty, setShowEmpty] = useState(loadReport.showEmpty); + const [showOffBudgetHidden, setShowOffBudgetHidden] = useState( + loadReport.showOffBudgetHidden, ); - const [endDate, setEndDate] = useState(monthUtils.currentMonth()); + const [showUncategorized, setShowUncategorized] = useState( + loadReport.showUncategorized, + ); + const [graphType, setGraphType] = useState(loadReport.graphType); - const [mode, setMode] = useState('total'); - const [isDateStatic, setIsDateStatic] = useState(false); - const [groupBy, setGroupBy] = useState('Category'); - const [balanceType, setBalanceType] = useState('Payment'); - const [showEmpty, setShowEmpty] = useState(false); - const [showOffBudgetHidden, setShowOffBudgetHidden] = useState(false); - const [showUncategorized, setShowUncategorized] = useState(false); const [dateRange, setDateRange] = useState('Last 6 months'); const [dataCheck, setDataCheck] = useState(false); - - const [graphType, setGraphType] = useState('BarGraph'); const dateRangeLine = ReportOptions.dateRange.length - 3; + const months = monthUtils.rangeInclusive(startDate, endDate); useEffect(() => { diff --git a/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx b/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx index 7269529b4..6a1e64572 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx @@ -13,7 +13,7 @@ import { ReportCard } from '../ReportCard'; import { createCustomSpreadsheet } from '../spreadsheets/custom-spreadsheet'; import { useReport } from '../useReport'; -export function CustomReportCard() { +export function CustomReportCard(reports) { const categories = useCategories(); const endDate = monthUtils.currentMonth(); @@ -32,7 +32,7 @@ export function CustomReportCard() { const data = useReport('default', getGraphData); return ( - <ReportCard flex={1} to="/reports/custom"> + <ReportCard flex={1} to="/reports/custom" reports={reports}> <View> <View style={{ flexDirection: 'row', padding: '20px 20px 0' }}> <View style={{ flex: 1 }}> diff --git a/packages/loot-core/src/client/data-hooks/reports.ts b/packages/loot-core/src/client/data-hooks/reports.ts new file mode 100644 index 000000000..1810a91f7 --- /dev/null +++ b/packages/loot-core/src/client/data-hooks/reports.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; + +import { q } from '../../shared/query'; +import { + type CustomReportData, + type CustomReportEntity, +} from '../../types/models'; +import { useLiveQuery } from '../query-hooks'; + +function toJS(rows: CustomReportData[]) { + const reports: CustomReportEntity[] = rows.map(row => { + const test: CustomReportEntity = { + ...row, + conditionsOp: row.conditions_op ?? 'and', + filters: row.conditions, + }; + return test; + }); + return reports; +} + +/* +leaving as a placeholder for saved reports implementation return an +empty array because "reports" db table doesn't exist yet +*/ +export function useReports(): CustomReportEntity[] { + const reports: CustomReportEntity[] = toJS( + //useLiveQuery(() => q('reports').select('*'), []) || [], + useLiveQuery(() => q('transaction_filters').select('*'), []) || [], + ); + + /** Sort reports by alphabetical order */ + function sort(reports: CustomReportEntity[]) { + return reports.sort((a, b) => + a.name + .trim() + .localeCompare(b.name.trim(), undefined, { ignorePunctuation: true }), + ); + } + + //return useMemo(() => sort(reports), [reports]); + + //everything below this line will be removed once db table is created + const order: CustomReportEntity[] = useMemo(() => sort(reports), [reports]); + const flag = true; + const emptyReports: CustomReportEntity[] = flag ? [] : order; + return emptyReports; +} diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index 005eada68..9d893e6b3 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -125,6 +125,26 @@ export const schema = { conditions: f('json'), tombstone: f('boolean'), }, + custom_reports: { + id: f('id'), + name: f('string'), + start_date: f('string', { default: '2023-06' }), + end_date: f('string', { default: '2023-09' }), + mode: f('string', { default: 'total' }), + group_by: f('string', { default: 'Category' }), + balance_type: f('string', { default: 'Expense' }), + interval: f('string', { default: 'Monthly' }), + show_empty: f('integer', { default: 0 }), + show_offbudgethidden: f('integer', { default: 0 }), + show_uncategorized: f('integer', { default: 0 }), + selected_categories: f('json'), + graph_type: f('string', { default: 'BarGraph' }), + conditions: f('json'), + conditions_op: f('string'), + metadata: f('json'), + color_scheme: f('json'), + tombstone: f('boolean'), + }, reflect_budgets: { id: f('id'), month: f('integer'), diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 03d661f06..89bf08968 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -48,6 +48,7 @@ import { app as notesApp } from './notes/app'; import * as Platform from './platform'; import { get, post } from './post'; import * as prefs from './prefs'; +import { app as reportsApp } from './reports/app'; import { app as rulesApp } from './rules/app'; import { app as schedulesApp } from './schedules/app'; import { getServer, setServer } from './server-config'; @@ -2147,7 +2148,15 @@ injectAPI.override((name, args) => runHandler(app.handlers[name], args)); // A hack for now until we clean up everything app.handlers = handlers; -app.combine(schedulesApp, budgetApp, notesApp, toolsApp, filtersApp, rulesApp); +app.combine( + schedulesApp, + budgetApp, + notesApp, + toolsApp, + filtersApp, + reportsApp, + rulesApp, +); function getDefaultDocumentDir() { if (Platform.isMobile) { diff --git a/packages/loot-core/src/server/reports/app.ts b/packages/loot-core/src/server/reports/app.ts new file mode 100644 index 000000000..612bc23ba --- /dev/null +++ b/packages/loot-core/src/server/reports/app.ts @@ -0,0 +1,100 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { + type CustomReportData, + type CustomReportEntity, +} from '../../types/models'; +import { parseConditionsOrActions } from '../accounts/transaction-rules'; +import { createApp } from '../app'; +import * as db from '../db'; +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 } = {}) { + requiredFields('reports', report, ['conditions'], update); + + if (!update || 'conditionsOp' in report) { + if (!['and', 'or'].includes(report.conditionsOp)) { + throw new Error('Invalid filter conditionsOp: ' + report.conditionsOp); + } + } + + return report; + }, + + toJS(row: CustomReportData) { + return { + ...row, + conditionsOp: row.conditions_op, + filters: parseConditionsOrActions(row.conditions), + }; + }, + + fromJS(report: CustomReportEntity) { + const { filters, conditionsOp, ...row }: CustomReportData = report; + if (conditionsOp) { + row.conditions_op = conditionsOp; + row.conditions = filters; + } + return row; + }, +}; + +async function reportNameExists( + name: string, + reportId: string | undefined, + newItem: boolean, +) { + if (!name) { + throw new Error('Report name is required'); + } + + if (!reportId) { + throw new Error('Report recall error'); + } + + const idForName: { id: string } = await db.first( + 'SELECT id from reports WHERE tombstone = 0 AND name = ?', + [name], + ); + + if (!newItem && idForName.id !== reportId) { + throw new Error('There is already a report named ' + name); + } +} + +async function createReport(report: CustomReportEntity) { + const reportId = uuidv4(); + const item: CustomReportData = { + ...report, + id: reportId, + }; + + reportNameExists(item.name, item.id, true); + + // Create the report here based on the info + await db.insertWithSchema('reports', reportModel.fromJS(item)); + + return reportId; +} + +async function updateReport(item: CustomReportEntity) { + reportNameExists(item.name, item.id, false); + + await db.insertWithSchema('reports', reportModel.fromJS(item)); +} + +async function deleteReport(id: string) { + await db.delete_('reports', id); +} + +// Expose functions to the client +export const app = createApp<ReportsHandlers>(); + +app.method('report/create', mutator(undoable(createReport))); +app.method('report/update', mutator(undoable(updateReport))); +app.method('report/delete', mutator(undoable(deleteReport))); diff --git a/packages/loot-core/src/server/reports/types/handlers.ts b/packages/loot-core/src/server/reports/types/handlers.ts new file mode 100644 index 000000000..4c8d3fbc6 --- /dev/null +++ b/packages/loot-core/src/server/reports/types/handlers.ts @@ -0,0 +1,9 @@ +import { type CustomReportEntity } from '../../../types/models'; + +export interface ReportsHandlers { + 'report/create': (report: CustomReportEntity) => Promise<string>; + + 'report/update': (report: CustomReportEntity) => Promise<void>; + + 'report/delete': (id: string) => Promise<void>; +} diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index 7d131d445..5eb8f281a 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -1,6 +1,7 @@ import type { BudgetHandlers } from '../server/budget/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'; import type { RulesHandlers } from '../server/rules/types/handlers'; import type { SchedulesHandlers } from '../server/schedules/types/handlers'; import type { ToolsHandlers } from '../server/tools/types/handlers'; @@ -14,6 +15,7 @@ export interface Handlers BudgetHandlers, FiltersHandlers, NotesHandlers, + ReportsHandlers, RulesHandlers, SchedulesHandlers, ToolsHandlers {} diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts index 7d15406b0..588b475a3 100644 --- a/packages/loot-core/src/types/models/reports.d.ts +++ b/packages/loot-core/src/types/models/reports.d.ts @@ -1,7 +1,7 @@ import { type RuleConditionEntity } from './rule'; export interface CustomReportEntity { - reportId?: string; + id: string | undefined; mode: string; groupBy: string; balanceType: string; @@ -10,13 +10,13 @@ export interface CustomReportEntity { showUncategorized: boolean; graphType: string; selectedCategories; - filters: RuleConditionEntity; + filters?: RuleConditionEntity[]; conditionsOp: string; name: string; startDate: string; endDate: string; isDateStatic: boolean; - data: GroupedEntity; + data?: GroupedEntity; tombstone?: boolean; } @@ -67,3 +67,8 @@ export interface DataEntity { export type Month = { month: string; }; + +export interface CustomReportData extends CustomReportEntity { + conditions_op?: string; + conditions?: RuleConditionEntity[]; +} diff --git a/upcoming-release-notes/2246.md b/upcoming-release-notes/2246.md new file mode 100644 index 000000000..e3bf2978e --- /dev/null +++ b/upcoming-release-notes/2246.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [carkom] +--- + +Add schema and backend functionality for custom reports. This is to enable saving reports in a future PR. -- GitLab