Retrofit useReports to use react-query under the hood (#6951)

* Retrofit useReports to use react-query under the hood

* Add release notes for PR #6951

* Update 6951.md

* Report mutations

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Joel Jeremy Marquez
2026-02-12 15:47:05 -08:00
committed by GitHub
parent 6f7af102a6
commit 8ae90a7ad1
14 changed files with 278 additions and 139 deletions

View File

@@ -142,7 +142,7 @@ export function CommandBar() {
}, [open]);
const allAccounts = useAccounts();
const { data: customReports } = useReports();
const { data: customReports = [] } = useReports();
const accounts = allAccounts.filter(acc => !acc.closed);

View File

@@ -16,7 +16,7 @@ export function ReportAutocomplete({
embedded,
...props
}: ReportAutocompleteProps) {
const { data: reports } = useReports();
const { data: reports = [] } = useReports();
return (
<Autocomplete

View File

@@ -83,7 +83,7 @@ export function Overview({ dashboard }: OverviewProps) {
? 'mobile'
: 'desktop';
const { data: customReports, isLoading: isCustomReportsLoading } =
const { data: customReports = [], isPending: isCustomReportsLoading } =
useReports();
const customReportMap = useMemo(

View File

@@ -9,7 +9,7 @@ import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { send, sendCatch } from 'loot-core/platform/client/fetch';
import { send } from 'loot-core/platform/client/fetch';
import type {
CustomReportEntity,
DashboardEntity,
@@ -25,6 +25,11 @@ import { SaveReportName } from './SaveReportName';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import { useDashboardPages } from '@desktop-client/hooks/useDashboard';
import { useReports } from '@desktop-client/hooks/useReports';
import {
useCreateReportMutation,
useDeleteReportMutation,
useUpdateReportMutation,
} from '@desktop-client/reports/mutations';
type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
customReportItems: T;
@@ -77,7 +82,7 @@ export function SaveReport({
onReportChange,
dashboardPages,
}: SaveReportProps) {
const { data: listReports } = useReports();
const { data: listReports = [] } = useReports();
const triggerRef = useRef(null);
const [deleteMenuOpen, setDeleteMenuOpen] = useState(false);
const [nameMenuOpen, setNameMenuOpen] = useState(false);
@@ -92,6 +97,9 @@ export function SaveReport({
dashboardPages.length > 0 ? dashboardPages[0].id : null,
);
const createReportMutation = useCreateReportMutation();
const updateReportMutation = useUpdateReportMutation();
async function onApply(cond: string) {
const chooseSavedReport = listReports.find(r => cond === r.id);
onReportChange({ savedReport: chooseSavedReport, type: 'choose' });
@@ -115,29 +123,34 @@ export function SaveReport({
return;
}
const response = await sendCatch('report/create', newSavedReport);
createReportMutation.mutate(
{ report: newSavedReport },
{
onSuccess: async id => {
await send('dashboard-add-widget', {
type: 'custom-report',
width: 4,
height: 2,
meta: { id },
dashboard_page_id: saveDashboardId,
});
if (response.error) {
setErr(response.error.message);
setNameMenuOpen(true);
return;
}
await send('dashboard-add-widget', {
type: 'custom-report',
width: 4,
height: 2,
meta: { id: response.data },
dashboard_page_id: saveDashboardId,
});
setNameMenuOpen(false);
onReportChange({
savedReport: {
...newSavedReport,
id: response.data,
setNameMenuOpen(false);
onReportChange({
savedReport: {
...newSavedReport,
id,
},
type: 'add-update',
});
},
onError: error => {
setErr(error.message);
setNameMenuOpen(true);
},
},
type: 'add-update',
});
);
return;
}
@@ -148,25 +161,37 @@ export function SaveReport({
...(menuChoice === 'rename-report' ? { name: newName } : props),
};
const response = await sendCatch('report/update', updatedReport);
if (response.error) {
setErr(response.error.message);
setNameMenuOpen(true);
return;
}
setNameMenuOpen(false);
onReportChange({
savedReport: updatedReport,
type: menuChoice === 'rename-report' ? 'rename' : 'add-update',
});
updateReportMutation.mutate(
{ report: updatedReport },
{
onSuccess: () => {
setNameMenuOpen(false);
onReportChange({
savedReport: updatedReport,
type: menuChoice === 'rename-report' ? 'rename' : 'add-update',
});
},
onError: error => {
setErr(error.message);
setNameMenuOpen(true);
},
},
);
};
const deleteReportMutation = useDeleteReportMutation();
const onDelete = async () => {
setNewName('');
await send('report/delete', report.id);
onReportChange({ type: 'reset' });
setDeleteMenuOpen(false);
deleteReportMutation.mutate(
{ id: report.id },
{
onSuccess: () => {
setNewName('');
onReportChange({ type: 'reset' });
setDeleteMenuOpen(false);
},
},
);
};
const onMenuSelect = async (item: string) => {

View File

@@ -121,9 +121,9 @@ function useSelectedCategories(
export function CustomReport() {
const params = useParams();
const { data: report, isLoading } = useCustomReport(params.id ?? '');
const { data: report, isPending } = useCustomReport(params.id);
if (isLoading) {
if (isPending) {
return <LoadingIndicator />;
}

View File

@@ -8,7 +8,7 @@ import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { send, sendCatch } from 'loot-core/platform/client/fetch';
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import type { CustomReportEntity } from 'loot-core/types/models';
@@ -26,6 +26,7 @@ import { usePayees } from '@desktop-client/hooks/usePayees';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import { useUpdateReportMutation } from '@desktop-client/reports/mutations';
type CustomReportListCardsProps = {
isEditing?: boolean;
@@ -106,30 +107,35 @@ function CustomReportListCardsInner({
run();
}, []);
const updateReportMutation = useUpdateReportMutation();
const onSaveName = async (name: string) => {
const updatedReport = {
...report,
name,
};
const response = await sendCatch('report/update', updatedReport);
if (response.error) {
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed saving report name: {{error}}', {
error: response.error.message,
updateReportMutation.mutate(
{ report: updatedReport },
{
onSuccess: () => {
setNameMenuOpen(false);
},
onError: error => {
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed saving report name: {{error}}', {
error: error.message,
}),
},
}),
},
}),
);
setNameMenuOpen(true);
return;
}
setNameMenuOpen(false);
);
setNameMenuOpen(true);
},
},
);
};
return (

View File

@@ -1,15 +1,11 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useReports } from './useReports';
import { reportQueries } from '@desktop-client/reports';
export function useReport(id: string) {
const { data, isLoading } = useReports();
return useMemo(
() => ({
data: data.find(report => report.id === id),
isLoading,
}),
[data, id, isLoading],
);
export function useReport(id?: string | null) {
return useQuery({
...reportQueries.list(),
select: reports => reports.find(report => report.id === id),
enabled: !!id,
});
}

View File

@@ -1,65 +1,7 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { q } from 'loot-core/shared/query';
import type {
CustomReportData,
CustomReportEntity,
} from 'loot-core/types/models';
import { useQuery } from './useQuery';
function toJS(rows: CustomReportData[]) {
const reports: CustomReportEntity[] = rows.map(row => {
const report: CustomReportEntity = {
id: row.id,
name: row.name,
startDate: row.start_date,
endDate: row.end_date,
isDateStatic: row.date_static === 1,
dateRange: row.date_range,
mode: row.mode,
groupBy: row.group_by,
sortBy: row.sort_by,
interval: row.interval,
balanceType: row.balance_type,
showEmpty: row.show_empty === 1,
showOffBudget: row.show_offbudget === 1,
showHiddenCategories: row.show_hidden === 1,
includeCurrentInterval: row.include_current === 1,
showUncategorized: row.show_uncategorized === 1,
trimIntervals: row.trim_intervals === 1,
graphType: row.graph_type,
...(row.conditions && { conditions: row.conditions }),
conditionsOp: row.conditions_op ?? 'and',
...(row.metadata && { metadata: row.metadata }),
};
return report;
});
return reports;
}
import { reportQueries } from '@desktop-client/reports';
export function useReports() {
const { data: queryData, isLoading } = useQuery<CustomReportData>(
() => q('custom_reports').select('*'),
[],
);
// Sort reports by alphabetical order
function sort(reports: CustomReportEntity[]) {
return reports.sort((a, b) =>
a.name && b.name
? a.name.trim().localeCompare(b.name.trim(), undefined, {
ignorePunctuation: true,
})
: 0,
);
}
return useMemo(
() => ({
isLoading,
data: sort(toJS(queryData ? [...queryData] : [])),
}),
[isLoading, queryData],
);
return useQuery(reportQueries.list());
}

View File

@@ -0,0 +1 @@
export * from './queries';

View File

@@ -0,0 +1,122 @@
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { sendCatch } from 'loot-core/platform/client/fetch';
import type { send } from 'loot-core/platform/client/fetch';
import { logger } from 'loot-core/platform/server/log';
import type { CustomReportEntity } from 'loot-core/types/models';
import { reportQueries } from '.';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
const sendThrow: typeof send = async (name, args) => {
const { error, data } = await sendCatch(name, args);
if (error) {
throw error;
}
return data;
};
function invalidateQueries(queryClient: QueryClient, queryKey?: QueryKey) {
queryClient.invalidateQueries({
queryKey: queryKey ?? reportQueries.lists(),
});
}
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error ? error.message : undefined,
},
}),
);
}
type CreateReportMutationPayload = {
report: CustomReportEntity;
};
export function useCreateReportMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ report }: CreateReportMutationPayload) => {
return await sendThrow('report/create', report);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error creating report:', error);
dispatchErrorNotification(
dispatch,
t('There was an error creating the report. Please try again.'),
error,
);
},
});
}
type UpdateReportPayload = {
report: CustomReportEntity;
};
export function useUpdateReportMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ report }: UpdateReportPayload) => {
return await sendThrow('report/update', report);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error updating report:', error);
dispatchErrorNotification(
dispatch,
t('There was an error updating the report. Please try again.'),
error,
);
},
});
}
type DeleteReportPayload = {
id: CustomReportEntity['id'];
};
export function useDeleteReportMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id }: DeleteReportPayload) => {
return await sendThrow('report/delete', id);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error deleting report:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting the report. Please try again.'),
error,
);
},
});
}

View File

@@ -0,0 +1,16 @@
import { queryOptions } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/fetch';
import type { CustomReportEntity } from 'loot-core/types/models';
export const reportQueries = {
all: () => ['reports'],
lists: () => [...reportQueries.all(), 'lists'],
list: () =>
queryOptions<CustomReportEntity[]>({
queryKey: [...reportQueries.lists()],
queryFn: async () => {
return await send('report/get');
},
}),
};

View File

@@ -1,7 +1,10 @@
import { v4 as uuidv4 } from 'uuid';
import { q } from 'loot-core/shared/query';
import type { CustomReportData, CustomReportEntity } from '../../types/models';
import { createApp } from '../app';
import { aqlQuery } from '../aql';
import * as db from '../db';
import { ValidationError } from '../errors';
import { requiredFields } from '../models';
@@ -26,7 +29,7 @@ export const reportModel = {
return report;
},
toJS(row: CustomReportData) {
toJS(row: CustomReportData): CustomReportEntity {
return {
id: row.id,
name: row.name,
@@ -46,8 +49,9 @@ export const reportModel = {
trimIntervals: row.trim_intervals === 1,
includeCurrentInterval: row.include_current === 1,
graphType: row.graph_type,
conditions: row.conditions,
conditionsOp: row.conditions_op,
conditions: row.conditions ?? [],
conditionsOp: row.conditions_op ?? 'and',
metadata: row.metadata,
};
},
@@ -77,6 +81,25 @@ export const reportModel = {
},
};
// Sort reports by alphabetical order
function sort(reports: CustomReportEntity[]) {
return reports.sort((a, b) =>
a.name && b.name
? a.name.trim().localeCompare(b.name.trim(), undefined, {
ignorePunctuation: true,
})
: 0,
);
}
async function getReports() {
// Use aql because it auto deserialized json columns e.g. conditions
const { data }: { data: CustomReportData[] } = await aqlQuery(
q('custom_reports').select('*'),
);
return sort(data.map(reportModel.toJS));
}
async function reportNameExists(
name: string,
reportId: string,
@@ -150,6 +173,7 @@ async function deleteReport(id: CustomReportEntity['id']) {
}
export type ReportsHandlers = {
'report/get': typeof getReports;
'report/create': typeof createReport;
'report/update': typeof updateReport;
'report/delete': typeof deleteReport;
@@ -158,6 +182,7 @@ export type ReportsHandlers = {
// Expose functions to the client
export const app = createApp<ReportsHandlers>();
app.method('report/get', getReports);
app.method('report/create', mutator(undoable(createReport)));
app.method('report/update', mutator(undoable(updateReport)));
app.method('report/delete', mutator(undoable(deleteReport)));

View File

@@ -21,7 +21,7 @@ export type CustomReportEntity = {
graphType: string;
conditions?: RuleConditionEntity[];
conditionsOp: 'and' | 'or';
data?: GroupedEntity;
metadata?: GroupedEntity;
tombstone?: boolean;
};

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Refactor report data fetching to utilize React Query for improved performance and reliability.