mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 02:29:58 -05:00
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:
committed by
GitHub
parent
6f7af102a6
commit
8ae90a7ad1
@@ -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);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ReportAutocomplete({
|
||||
embedded,
|
||||
...props
|
||||
}: ReportAutocompleteProps) {
|
||||
const { data: reports } = useReports();
|
||||
const { data: reports = [] } = useReports();
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
1
packages/desktop-client/src/reports/index.ts
Normal file
1
packages/desktop-client/src/reports/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './queries';
|
||||
122
packages/desktop-client/src/reports/mutations.ts
Normal file
122
packages/desktop-client/src/reports/mutations.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
16
packages/desktop-client/src/reports/queries.ts
Normal file
16
packages/desktop-client/src/reports/queries.ts
Normal 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');
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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)));
|
||||
|
||||
@@ -21,7 +21,7 @@ export type CustomReportEntity = {
|
||||
graphType: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp: 'and' | 'or';
|
||||
data?: GroupedEntity;
|
||||
metadata?: GroupedEntity;
|
||||
tombstone?: boolean;
|
||||
};
|
||||
|
||||
|
||||
6
upcoming-release-notes/6951.md
Normal file
6
upcoming-release-notes/6951.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Refactor report data fetching to utilize React Query for improved performance and reliability.
|
||||
Reference in New Issue
Block a user