Move redux state to react-query - category states (#5977)

* Move redux state to react query - category states

* Fix typecheck errors

* Fix typecheck errors

* Fix typecheck errors

* Remove t argument

* [autofix.ci] apply automated fixes

* Coderabbot suggestion

* Code review feedback

* Fix type

* Coderabbit

* Delete useCategoryActions

* Fix lint

* Use categories from react query cache

* Fix typecheck error

* Update to use useDeleteCategoryGroupMutation

* Coderabbit feedback

* Break up useCategoryActions

* [autofix.ci] apply automated fixes

* Fix typecheck errors

* Fix typecheck error

* Fix typecheck error

* await nested mutations

* Await deleteCategory

* Rename to sendThrow

* Fix lint errors

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joel Jeremy Marquez
2026-02-04 16:42:49 -08:00
committed by GitHub
parent c57260a504
commit 7f6f4d5def
28 changed files with 1220 additions and 1170 deletions

View File

@@ -119,7 +119,7 @@
"react/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useQuery|useEffectAfterMount)"
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
}
],
"react/jsx-curly-brace-presence": "warn",

View File

@@ -33,6 +33,7 @@
"@rollup/plugin-inject": "^5.0.5",
"@swc/core": "^1.15.8",
"@swc/helpers": "^0.5.18",
"@tanstack/react-query": "^5.90.5",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "16.3.0",

View File

@@ -1,654 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import { t } from 'i18next';
import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/fetch';
import { type IntegerAmount } from 'loot-core/shared/util';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { resetApp } from '@desktop-client/app/appSlice';
import {
addGenericErrorNotification,
addNotification,
} from '@desktop-client/notifications/notificationsSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
const sliceName = 'budget';
type CategoryViews = {
grouped: CategoryGroupEntity[];
list: CategoryEntity[];
};
type BudgetState = {
categories: CategoryViews;
isCategoriesLoading: boolean;
isCategoriesLoaded: boolean;
isCategoriesDirty: boolean;
};
const initialState: BudgetState = {
categories: {
grouped: [],
list: [],
},
isCategoriesLoading: false,
isCategoriesLoaded: false,
isCategoriesDirty: false,
};
const budgetSlice = createSlice({
name: sliceName,
initialState,
reducers: {
markCategoriesDirty(state) {
_markCategoriesDirty(state);
},
},
extraReducers: builder => {
builder.addCase(resetApp, () => initialState);
builder.addCase(createCategoryGroup.fulfilled, _markCategoriesDirty);
builder.addCase(updateCategoryGroup.fulfilled, _markCategoriesDirty);
builder.addCase(deleteCategoryGroup.fulfilled, _markCategoriesDirty);
builder.addCase(createCategory.fulfilled, _markCategoriesDirty);
builder.addCase(updateCategory.fulfilled, _markCategoriesDirty);
builder.addCase(deleteCategory.fulfilled, _markCategoriesDirty);
builder.addCase(moveCategoryGroup.fulfilled, _markCategoriesDirty);
builder.addCase(moveCategory.fulfilled, _markCategoriesDirty);
builder.addCase(reloadCategories.fulfilled, (state, action) => {
_loadCategories(state, action.payload);
});
builder.addCase(reloadCategories.rejected, state => {
state.isCategoriesLoading = false;
});
builder.addCase(reloadCategories.pending, state => {
state.isCategoriesLoading = true;
});
builder.addCase(getCategories.fulfilled, (state, action) => {
_loadCategories(state, action.payload);
});
builder.addCase(getCategories.rejected, state => {
state.isCategoriesLoading = false;
});
builder.addCase(getCategories.pending, state => {
state.isCategoriesLoading = true;
});
},
});
type CreateCategoryGroupPayload = {
name: CategoryGroupEntity['name'];
};
export const createCategoryGroup = createAppAsyncThunk(
`${sliceName}/createCategoryGroup`,
async ({ name }: CreateCategoryGroupPayload) => {
const id = await send('category-group-create', { name });
return id;
},
);
type UpdateCategoryGroupPayload = {
group: CategoryGroupEntity;
};
export const updateCategoryGroup = createAppAsyncThunk(
`${sliceName}/updateCategoryGroup`,
async ({ group }: UpdateCategoryGroupPayload, { dispatch }) => {
// Strip off the categories field if it exist. It's not a real db
// field but groups have this extra field in the client most of the time
const categoryGroups = await send('get-categories');
if (
categoryGroups.grouped.find(
g =>
g.id !== group.id &&
g.name.toUpperCase() === group.name.toUpperCase(),
)
) {
dispatch(
addNotification({
notification: {
type: 'error',
message: t('A category group with this name already exists.'),
},
}),
);
return;
}
const { categories: _, ...groupNoCategories } = group;
await send('category-group-update', groupNoCategories);
},
);
type DeleteCategoryGroupPayload = {
id: CategoryGroupEntity['id'];
transferId?: CategoryGroupEntity['id'] | null;
};
export const deleteCategoryGroup = createAppAsyncThunk(
`${sliceName}/deleteCategoryGroup`,
async ({ id, transferId }: DeleteCategoryGroupPayload) => {
await send('category-group-delete', { id, transferId });
},
);
type CreateCategoryPayload = {
name: CategoryEntity['name'];
groupId: CategoryGroupEntity['id'];
isIncome: boolean;
isHidden: boolean;
};
export const createCategory = createAppAsyncThunk(
`${sliceName}/createCategory`,
async ({ name, groupId, isIncome, isHidden }: CreateCategoryPayload) => {
const id = await send('category-create', {
name,
groupId,
isIncome,
hidden: isHidden,
});
return id;
},
);
type UpdateCategoryPayload = {
category: CategoryEntity;
};
export const updateCategory = createAppAsyncThunk(
`${sliceName}/updateCategory`,
async ({ category }: UpdateCategoryPayload) => {
await send('category-update', category);
},
);
type DeleteCategoryPayload = {
id: CategoryEntity['id'];
transferId?: CategoryEntity['id'] | null;
};
export const deleteCategory = createAppAsyncThunk(
`${sliceName}/deleteCategory`,
async ({ id, transferId }: DeleteCategoryPayload, { dispatch }) => {
const { error } = await send('category-delete', { id, transferId });
if (error) {
switch (error) {
case 'category-type':
dispatch(
addNotification({
notification: {
id: `${sliceName}/deleteCategory/transfer`,
type: 'error',
message: t(
'A category must be transferred to another of the same type (expense or income)',
),
},
}),
);
break;
default:
dispatch(addGenericErrorNotification());
}
throw new Error(error);
}
},
);
type MoveCategoryPayload = {
id: CategoryEntity['id'];
groupId: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
};
export const moveCategory = createAppAsyncThunk(
`${sliceName}/moveCategory`,
async ({ id, groupId, targetId }: MoveCategoryPayload) => {
await send('category-move', { id, groupId, targetId });
},
);
type MoveCategoryGroupPayload = {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
};
export const moveCategoryGroup = createAppAsyncThunk(
`${sliceName}/moveCategoryGroup`,
async ({ id, targetId }: MoveCategoryGroupPayload) => {
await send('category-group-move', { id, targetId });
},
);
function translateCategories(
categories: CategoryEntity[] | undefined,
): CategoryEntity[] | undefined {
return categories?.map(cat => ({
...cat,
name:
cat.name?.toLowerCase() === 'starting balances'
? t('Starting Balances')
: cat.name,
}));
}
export const getCategories = createAppAsyncThunk(
`${sliceName}/getCategories`,
async () => {
const categories: CategoryViews = await send('get-categories');
categories.list = translateCategories(categories.list) as CategoryEntity[];
categories.grouped.forEach(group => {
group.categories = translateCategories(
group.categories,
) as CategoryEntity[];
});
return categories;
},
{
condition: (_, { getState }) => {
const { budget } = getState();
return (
!budget.isCategoriesLoading &&
(budget.isCategoriesDirty || !budget.isCategoriesLoaded)
);
},
},
);
export const reloadCategories = createAppAsyncThunk(
`${sliceName}/reloadCategories`,
async () => {
const categories: CategoryViews = await send('get-categories');
categories.list = translateCategories(categories.list) as CategoryEntity[];
categories.grouped.forEach(group => {
group.categories = translateCategories(
group.categories,
) as CategoryEntity[];
});
return categories;
},
);
type ApplyBudgetActionPayload =
| {
type: 'budget-amount';
month: string;
args: {
category: CategoryEntity['id'];
amount: number;
};
}
| {
type: 'copy-last';
month: string;
args: never;
}
| {
type: 'set-zero';
month: string;
args: never;
}
| {
type: 'set-3-avg';
month: string;
args: never;
}
| {
type: 'set-6-avg';
month: string;
args: never;
}
| {
type: 'set-12-avg';
month: string;
args: never;
}
| {
type: 'check-templates';
month: never;
args: never;
}
| {
type: 'apply-goal-template';
month: string;
args: never;
}
| {
type: 'overwrite-goal-template';
month: string;
args: never;
}
| {
type: 'cleanup-goal-template';
month: string;
args: never;
}
| {
type: 'hold';
month: string;
args: {
amount: number;
};
}
| {
type: 'reset-hold';
month: string;
args: never;
}
| {
type: 'cover-overspending';
month: string;
args: {
to: CategoryEntity['id'];
from: CategoryEntity['id'];
amount?: IntegerAmount;
currencyCode: string;
};
}
| {
type: 'transfer-available';
month: string;
args: {
amount: number;
category: CategoryEntity['id'];
};
}
| {
type: 'cover-overbudgeted';
month: string;
args: {
category: CategoryEntity['id'];
amount?: IntegerAmount;
currencyCode: string;
};
}
| {
type: 'transfer-category';
month: string;
args: {
amount: number;
from: CategoryEntity['id'];
to: CategoryEntity['id'];
currencyCode: string;
};
}
| {
type: 'carryover';
month: string;
args: {
category: CategoryEntity['id'];
flag: boolean;
};
}
| {
type: 'reset-income-carryover';
month: string;
args: never;
}
| {
type: 'apply-single-category-template';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'apply-multiple-templates';
month: string;
args: {
categories: Array<CategoryEntity['id']>;
};
}
| {
type: 'set-single-3-avg';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'set-single-6-avg';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'set-single-12-avg';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'copy-single-last';
month: string;
args: {
category: CategoryEntity['id'];
};
};
export const applyBudgetAction = createAppAsyncThunk(
`${sliceName}/applyBudgetAction`,
async ({ month, type, args }: ApplyBudgetActionPayload, { dispatch }) => {
switch (type) {
case 'budget-amount':
await send('budget/budget-amount', {
month,
category: args.category,
amount: args.amount,
});
break;
case 'copy-last':
await send('budget/copy-previous-month', { month });
break;
case 'set-zero':
await send('budget/set-zero', { month });
break;
case 'set-3-avg':
await send('budget/set-3month-avg', { month });
break;
case 'set-6-avg':
await send('budget/set-6month-avg', { month });
break;
case 'set-12-avg':
await send('budget/set-12month-avg', { month });
break;
case 'check-templates':
dispatch(
addNotification({
notification: await send('budget/check-templates'),
}),
);
break;
case 'apply-goal-template':
dispatch(
addNotification({
notification: await send('budget/apply-goal-template', { month }),
}),
);
break;
case 'overwrite-goal-template':
dispatch(
addNotification({
notification: await send('budget/overwrite-goal-template', {
month,
}),
}),
);
break;
case 'apply-single-category-template':
dispatch(
addNotification({
notification: await send('budget/apply-single-template', {
month,
category: args.category,
}),
}),
);
break;
case 'cleanup-goal-template':
dispatch(
addNotification({
notification: await send('budget/cleanup-goal-template', { month }),
}),
);
break;
case 'hold':
await send('budget/hold-for-next-month', {
month,
amount: args.amount,
});
break;
case 'reset-hold':
await send('budget/reset-hold', { month });
break;
case 'cover-overspending':
await send('budget/cover-overspending', {
month,
to: args.to,
from: args.from,
amount: args.amount,
currencyCode: args.currencyCode,
});
break;
case 'transfer-available':
await send('budget/transfer-available', {
month,
amount: args.amount,
category: args.category,
});
break;
case 'cover-overbudgeted':
await send('budget/cover-overbudgeted', {
month,
category: args.category,
amount: args.amount,
currencyCode: args.currencyCode,
});
break;
case 'transfer-category':
await send('budget/transfer-category', {
month,
amount: args.amount,
from: args.from,
to: args.to,
currencyCode: args.currencyCode,
});
break;
case 'carryover': {
await send('budget/set-carryover', {
startMonth: month,
category: args.category,
flag: args.flag,
});
break;
}
case 'reset-income-carryover':
await send('budget/reset-income-carryover', { month });
break;
case 'apply-multiple-templates':
dispatch(
addNotification({
notification: await send('budget/apply-multiple-templates', {
month,
categoryIds: args.categories,
}),
}),
);
break;
case 'set-single-3-avg':
await send('budget/set-n-month-avg', {
month,
N: 3,
category: args.category,
});
break;
case 'set-single-6-avg':
await send('budget/set-n-month-avg', {
month,
N: 6,
category: args.category,
});
break;
case 'set-single-12-avg':
await send('budget/set-n-month-avg', {
month,
N: 12,
category: args.category,
});
break;
case 'copy-single-last':
await send('budget/copy-single-month', {
month,
category: args.category,
});
break;
default:
console.log(`Invalid action type: ${type}`);
}
},
);
export const getCategoriesById = memoizeOne(
(categoryGroups: CategoryGroupEntity[] | null | undefined) => {
const res: { [id: CategoryEntity['id']]: CategoryEntity } = {};
categoryGroups?.forEach(group => {
group.categories?.forEach(cat => {
res[cat.id] = cat;
});
});
return res;
},
);
export const { name, reducer, getInitialState } = budgetSlice;
export const actions = {
...budgetSlice.actions,
applyBudgetAction,
getCategories,
reloadCategories,
createCategoryGroup,
updateCategoryGroup,
deleteCategoryGroup,
createCategory,
updateCategory,
deleteCategory,
moveCategory,
moveCategoryGroup,
};
export const { markCategoriesDirty } = budgetSlice.actions;
function _loadCategories(
state: BudgetState,
categories: BudgetState['categories'],
) {
state.categories = categories;
categories.list = translateCategories(categories.list) as CategoryEntity[];
categories.grouped.forEach(group => {
group.categories = translateCategories(
group.categories,
) as CategoryEntity[];
});
state.isCategoriesLoading = false;
state.isCategoriesLoaded = true;
state.isCategoriesDirty = false;
}
function _markCategoriesDirty(state: BudgetState) {
state.isCategoriesDirty = true;
}

View File

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

View File

@@ -0,0 +1,843 @@
import { useTranslation } from 'react-i18next';
import {
useMutation,
useQueryClient,
type QueryClient,
type QueryKey,
} from '@tanstack/react-query';
import { type TFunction } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { sendCatch, type send } from 'loot-core/platform/client/fetch';
import { logger } from 'loot-core/platform/server/log';
import { type IntegerAmount } from 'loot-core/shared/util';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { categoryQueries } from '.';
import { pushModal } from '@desktop-client/modals/modalsSlice';
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 ?? categoryQueries.lists(),
});
}
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error ? error.message : undefined,
},
}),
);
}
function dispatchCategoryNameAlreadyExistsNotification(
dispatch: AppDispatch,
t: TFunction,
name: CategoryEntity['name'],
) {
dispatch(
addNotification({
notification: {
type: 'error',
message: t(
'Category "{{name}}" already exists in group (it may be hidden)',
{ name },
),
},
}),
);
}
type CreateCategoryPayload = {
name: CategoryEntity['name'];
groupId: CategoryGroupEntity['id'];
isIncome: boolean;
isHidden: boolean;
};
export function useCreateCategoryMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({
name,
groupId,
isIncome,
isHidden,
}: CreateCategoryPayload) => {
const id = await sendThrow('category-create', {
name,
groupId,
isIncome,
hidden: isHidden,
});
return id;
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error creating category:', error);
dispatchErrorNotification(
dispatch,
t('There was an error creating the category. Please try again.'),
error,
);
throw error;
},
});
}
type UpdateCategoryPayload = {
category: CategoryEntity;
};
export function useUpdateCategoryMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ category }: UpdateCategoryPayload) => {
await sendThrow('category-update', category);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error updating category:', error);
dispatchErrorNotification(
dispatch,
t('There was an error updating the category. Please try again.'),
error,
);
throw error;
},
});
}
type SaveCategoryPayload = {
category: CategoryEntity;
};
export function useSaveCategoryMutation() {
const createCategory = useCreateCategoryMutation();
const updateCategory = useUpdateCategoryMutation();
const { t } = useTranslation();
const dispatch = useDispatch();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ category }: SaveCategoryPayload) => {
const { grouped: categoryGroups } = await queryClient.ensureQueryData(
categoryQueries.list(),
);
const group = categoryGroups.find(g => g.id === category.group);
const categoriesInGroup = group?.categories ?? [];
const exists = categoriesInGroup.some(c =>
category.id === 'new'
? true
: c.id !== category.id &&
c.name.toUpperCase() === category.name.toUpperCase(),
);
if (exists) {
dispatchCategoryNameAlreadyExistsNotification(
dispatch,
t,
category.name,
);
return;
}
if (category.id === 'new') {
await createCategory.mutateAsync({
name: category.name,
groupId: category.group,
isIncome: !!category.is_income,
isHidden: !!category.hidden,
});
} else {
await updateCategory.mutateAsync({ category });
}
},
});
}
type DeleteCategoryPayload = {
id: CategoryEntity['id'];
};
export function useDeleteCategoryMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const deleteCategory = async ({
id,
transferId,
}: {
id: CategoryEntity['id'];
transferId?: CategoryEntity['id'];
}) => {
await sendThrow('category-delete', { id, transferId });
};
return useMutation({
mutationFn: async ({ id }: DeleteCategoryPayload) => {
const mustTransfer = await sendThrow('must-category-transfer', { id });
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
category: id,
onDelete: async transferCategory => {
if (id !== transferCategory) {
await deleteCategory({ id, transferId: transferCategory });
}
},
},
},
}),
);
} else {
await deleteCategory({ id });
}
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error deleting category:', error);
if (error) {
switch (error.cause) {
case 'category-type':
dispatchErrorNotification(
dispatch,
t(
'A category must be transferred to another of the same type (expense or income)',
),
error,
);
break;
default:
dispatchErrorNotification(
dispatch,
t('There was an error deleting the category. Please try again.'),
error,
);
}
}
throw error;
},
});
}
type MoveCategoryPayload = {
id: CategoryEntity['id'];
groupId: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
};
export function useMoveCategoryMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id, groupId, targetId }: MoveCategoryPayload) => {
await sendThrow('category-move', { id, groupId, targetId });
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error moving category:', error);
dispatchErrorNotification(
dispatch,
t('There was an error moving the category. Please try again.'),
error,
);
throw error;
},
});
}
type ReoderCategoryPayload = {
id: CategoryEntity['id'];
groupId: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
};
export function useReorderCategoryMutation() {
const moveCategory = useMoveCategoryMutation();
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id, groupId, targetId }: ReoderCategoryPayload) => {
const { grouped: categoryGroups, list: categories } =
await queryClient.ensureQueryData(categoryQueries.list());
const moveCandidate = categories.filter(c => c.id === id)[0];
const group = categoryGroups.find(g => g.id === groupId);
const categoriesInGroup = group?.categories ?? [];
const exists = categoriesInGroup.some(
c =>
c.id !== moveCandidate.id &&
c.name.toUpperCase() === moveCandidate.name.toUpperCase(),
);
if (exists) {
dispatchCategoryNameAlreadyExistsNotification(
dispatch,
t,
moveCandidate.name,
);
return;
}
await moveCategory.mutateAsync({ id, groupId, targetId });
},
});
}
type CreateCategoryGroupPayload = {
name: CategoryGroupEntity['name'];
};
export function useCreateCategoryGroupMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ name }: CreateCategoryGroupPayload) => {
const id = await sendThrow('category-group-create', { name });
return id;
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error creating category group:', error);
dispatchErrorNotification(
dispatch,
t('There was an error creating the category group. Please try again.'),
error,
);
throw error;
},
});
}
type UpdateCategoryGroupPayload = {
group: CategoryGroupEntity;
};
export function useUpdateCategoryGroupMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ group }: UpdateCategoryGroupPayload) => {
const { grouped: categoryGroups } = await queryClient.ensureQueryData(
categoryQueries.list(),
);
const exists = categoryGroups.some(
g =>
g.id !== group.id &&
g.name.toUpperCase() === group.name.toUpperCase(),
);
if (exists) {
dispatchErrorNotification(
dispatch,
t('A category group with name "{{name}}" already exists.', {
name: group.name,
}),
);
return;
}
// Strip off the categories field if it exist. It's not a real db
// field but groups have this extra field in the client most of the time
const { categories: _, ...groupNoCategories } = group;
await sendThrow('category-group-update', groupNoCategories);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error updating category group:', error);
dispatchErrorNotification(
dispatch,
t('There was an error updating the category group. Please try again.'),
error,
);
throw error;
},
});
}
type SaveCategoryGroupPayload = {
group: CategoryGroupEntity;
};
export function useSaveCategoryGroupMutation() {
const createCategoryGroup = useCreateCategoryGroupMutation();
const updateCategoryGroup = useUpdateCategoryGroupMutation();
return useMutation({
mutationFn: async ({ group }: SaveCategoryGroupPayload) => {
if (group.id === 'new') {
await createCategoryGroup.mutateAsync({ name: group.name });
} else {
await updateCategoryGroup.mutateAsync({ group });
}
},
});
}
type DeleteCategoryGroupPayload = {
id: CategoryGroupEntity['id'];
};
export function useDeleteCategoryGroupMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id }: DeleteCategoryGroupPayload) => {
const { grouped: categoryGroups } = await queryClient.ensureQueryData(
categoryQueries.list(),
);
const group = categoryGroups.find(g => g.id === id);
if (!group) {
return;
}
const categories = group.categories ?? [];
let mustTransfer = false;
for (const category of categories) {
if (await sendThrow('must-category-transfer', { id: category.id })) {
mustTransfer = true;
break;
}
}
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
group: id,
onDelete: async transferCategory => {
await sendThrow('category-group-delete', {
id,
transferId: transferCategory,
});
},
},
},
}),
);
} else {
await sendThrow('category-group-delete', { id });
}
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error deleting category group:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting the category group. Please try again.'),
error,
);
throw error;
},
});
}
type MoveCategoryGroupPayload = {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
};
export function useMoveCategoryGroupMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id, targetId }: MoveCategoryGroupPayload) => {
await sendThrow('category-group-move', { id, targetId });
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
logger.error('Error moving category group:', error);
dispatchErrorNotification(
dispatch,
t('There was an error moving the category group. Please try again.'),
error,
);
throw error;
},
});
}
type ReorderCategoryGroupPayload = {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
};
export function useReorderCategoryGroupMutation() {
const moveCategoryGroup = useMoveCategoryGroupMutation();
return useMutation({
mutationFn: async (sortInfo: ReorderCategoryGroupPayload) => {
await moveCategoryGroup.mutateAsync({
id: sortInfo.id,
targetId: sortInfo.targetId,
});
},
});
}
type ApplyBudgetActionPayload =
| {
type: 'budget-amount';
month: string;
args: {
category: CategoryEntity['id'];
amount: number;
};
}
| {
type: 'copy-last';
month: string;
args?: never;
}
| {
type: 'set-zero';
month: string;
args?: never;
}
| {
type: 'set-3-avg';
month: string;
args?: never;
}
| {
type: 'set-6-avg';
month: string;
args?: never;
}
| {
type: 'set-12-avg';
month: string;
args?: never;
}
| {
type: 'check-templates';
month?: never;
args?: never;
}
| {
type: 'apply-goal-template';
month: string;
args?: never;
}
| {
type: 'overwrite-goal-template';
month: string;
args?: never;
}
| {
type: 'cleanup-goal-template';
month: string;
args?: never;
}
| {
type: 'hold';
month: string;
args: {
amount: number;
};
}
| {
type: 'reset-hold';
month: string;
args?: never;
}
| {
type: 'cover-overspending';
month: string;
args: {
to: CategoryEntity['id'];
from: CategoryEntity['id'];
amount?: IntegerAmount;
currencyCode: string;
};
}
| {
type: 'transfer-available';
month: string;
args: {
amount: number;
category: CategoryEntity['id'];
};
}
| {
type: 'cover-overbudgeted';
month: string;
args: {
category: CategoryEntity['id'];
amount?: IntegerAmount;
currencyCode: string;
};
}
| {
type: 'transfer-category';
month: string;
args: {
amount: number;
from: CategoryEntity['id'];
to: CategoryEntity['id'];
currencyCode: string;
};
}
| {
type: 'carryover';
month: string;
args: {
category: CategoryEntity['id'];
flag: boolean;
};
}
| {
type: 'reset-income-carryover';
month: string;
args?: never;
}
| {
type: 'apply-single-category-template';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'apply-multiple-templates';
month: string;
args: {
categories: Array<CategoryEntity['id']>;
};
}
| {
type: 'set-single-3-avg';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'set-single-6-avg';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'set-single-12-avg';
month: string;
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'copy-single-last';
month: string;
args: {
category: CategoryEntity['id'];
};
};
export function useBudgetActions() {
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ month, type, args }: ApplyBudgetActionPayload) => {
switch (type) {
case 'budget-amount':
await sendThrow('budget/budget-amount', {
month,
category: args.category,
amount: args.amount,
});
return null;
case 'copy-last':
await sendThrow('budget/copy-previous-month', { month });
return null;
case 'set-zero':
await sendThrow('budget/set-zero', { month });
return null;
case 'set-3-avg':
await sendThrow('budget/set-3month-avg', { month });
return null;
case 'set-6-avg':
await sendThrow('budget/set-6month-avg', { month });
return null;
case 'set-12-avg':
await sendThrow('budget/set-12month-avg', { month });
return null;
case 'check-templates':
return await sendThrow('budget/check-templates');
case 'apply-goal-template':
return await sendThrow('budget/apply-goal-template', { month });
case 'overwrite-goal-template':
return await sendThrow('budget/overwrite-goal-template', { month });
case 'apply-single-category-template':
return await sendThrow('budget/apply-single-template', {
month,
category: args.category,
});
case 'cleanup-goal-template':
return await sendThrow('budget/cleanup-goal-template', { month });
case 'hold':
await sendThrow('budget/hold-for-next-month', {
month,
amount: args.amount,
});
return null;
case 'reset-hold':
await sendThrow('budget/reset-hold', { month });
return null;
case 'cover-overspending':
await sendThrow('budget/cover-overspending', {
month,
to: args.to,
from: args.from,
amount: args.amount,
currencyCode: args.currencyCode,
});
return null;
case 'transfer-available':
await sendThrow('budget/transfer-available', {
month,
amount: args.amount,
category: args.category,
});
return null;
case 'cover-overbudgeted':
await sendThrow('budget/cover-overbudgeted', {
month,
category: args.category,
amount: args.amount,
currencyCode: args.currencyCode,
});
return null;
case 'transfer-category':
await sendThrow('budget/transfer-category', {
month,
amount: args.amount,
from: args.from,
to: args.to,
currencyCode: args.currencyCode,
});
return null;
case 'carryover': {
await sendThrow('budget/set-carryover', {
startMonth: month,
category: args.category,
flag: args.flag,
});
return null;
}
case 'reset-income-carryover':
await sendThrow('budget/reset-income-carryover', { month });
return null;
case 'apply-multiple-templates':
return await sendThrow('budget/apply-multiple-templates', {
month,
categoryIds: args.categories,
});
case 'set-single-3-avg':
await sendThrow('budget/set-n-month-avg', {
month,
N: 3,
category: args.category,
});
return null;
case 'set-single-6-avg':
await sendThrow('budget/set-n-month-avg', {
month,
N: 6,
category: args.category,
});
return null;
case 'set-single-12-avg':
await sendThrow('budget/set-n-month-avg', {
month,
N: 12,
category: args.category,
});
return null;
case 'copy-single-last':
await sendThrow('budget/copy-single-month', {
month,
category: args.category,
});
return null;
default:
throw new Error(`Unknown budget action type: ${type}`);
}
},
onSuccess: notification => {
if (notification) {
dispatch(
addNotification({
notification,
}),
);
}
},
onError: error => {
logger.error('Error applying budget action:', error);
dispatchErrorNotification(
dispatch,
t('There was an error applying the budget action. Please try again.'),
error,
);
throw error;
},
});
}

View File

@@ -0,0 +1,65 @@
import { queryOptions } from '@tanstack/react-query';
import i18n from 'i18next';
import { send } from 'loot-core/platform/client/fetch';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
type CategoryViews = {
grouped: CategoryGroupEntity[];
list: CategoryEntity[];
};
export const categoryQueries = {
all: () => ['categories'],
lists: () => [...categoryQueries.all(), 'lists'],
list: () =>
queryOptions<CategoryViews>({
queryKey: [...categoryQueries.lists()],
queryFn: async () => {
const categories = await send('get-categories');
return translateStartingBalances(categories);
},
placeholderData: {
grouped: [],
list: [],
},
// Manually invalidated when categories change
staleTime: Infinity,
}),
};
function translateStartingBalances(categories: {
grouped: CategoryGroupEntity[];
list: CategoryEntity[];
}): CategoryViews {
return {
list: translateStartingBalancesCategories(categories.list) ?? [],
grouped: categories.grouped.map(group => ({
...group,
categories: translateStartingBalancesCategories(group.categories),
})),
};
}
function translateStartingBalancesCategories(
categories: CategoryEntity[] | undefined,
): CategoryEntity[] | undefined {
return categories
? categories.map(cat => translateStartingBalancesCategory(cat))
: undefined;
}
function translateStartingBalancesCategory(
category: CategoryEntity,
): CategoryEntity {
return {
...category,
name:
category.name?.toLowerCase() === 'starting balances'
? i18n.t('Starting Balances')
: category.name,
};
}

View File

@@ -13,6 +13,7 @@ import { BrowserRouter } from 'react-router';
import { styles } from '@actual-app/components/styles';
import { View } from '@actual-app/components/view';
import { useQueryClient } from '@tanstack/react-query';
import { init as initConnection, send } from 'loot-core/platform/client/fetch';
@@ -173,8 +174,9 @@ function ErrorFallback({ error }: FallbackProps) {
export function App() {
const store = useStore();
const isTestEnv = useIsTestEnv();
const queryClient = useQueryClient();
useEffect(() => handleGlobalEvents(store), [store]);
useEffect(() => handleGlobalEvents(store, queryClient), [store, queryClient]);
const [hiddenScrollbars, setHiddenScrollbars] = useState(
hasHiddenScrollbars(),

View File

@@ -236,7 +236,7 @@ export function CategoryAutocomplete({
showHiddenCategories,
...props
}: CategoryAutocompleteProps) {
const { grouped: defaultCategoryGroups = [] } = useCategories();
const { grouped: defaultCategoryGroups } = useCategories();
const categorySuggestions: CategoryAutocompleteItem[] = useMemo(() => {
const allSuggestions = (categoryGroups || defaultCategoryGroups).reduce(
(list, group) =>

View File

@@ -42,7 +42,7 @@ type BudgetTableProps = {
) => void;
onReorderCategory: (params: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
groupId: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
}) => void;
onReorderGroup: (params: {
@@ -71,7 +71,7 @@ export function BudgetTable(props: BudgetTableProps) {
onBudgetAction,
} = props;
const { grouped: categoryGroups = [] } = useCategories();
const { grouped: categoryGroups } = useCategories();
const [collapsedGroupIds = [], setCollapsedGroupIdsPref] =
useLocalPref('budget.collapsed');
const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref(
@@ -118,20 +118,17 @@ export function BudgetTable(props: BudgetTableProps) {
});
}
} else {
let targetGroup;
const group = categoryGroups.find(({ categories = [] }) =>
categories.some(cat => cat.id === targetId),
);
for (const group of categoryGroups) {
if (group.categories?.find(cat => cat.id === targetId)) {
targetGroup = group;
break;
}
if (group) {
onReorderCategory({
id,
groupId: group.id,
...findSortDown(group.categories || [], dropPos, targetId),
});
}
onReorderCategory({
id,
groupId: targetGroup?.id,
...findSortDown(targetGroup?.categories || [], dropPos, targetId),
});
}
};

View File

@@ -25,22 +25,26 @@ import { TrackingBudgetProvider } from './tracking/TrackingBudgetContext';
import { prewarmAllMonths, prewarmMonth } from './util';
import {
applyBudgetAction,
getCategories,
} from '@desktop-client/budget/budgetSlice';
useBudgetActions,
useDeleteCategoryGroupMutation,
useDeleteCategoryMutation,
useReorderCategoryGroupMutation,
useReorderCategoryMutation,
useSaveCategoryGroupMutation,
useSaveCategoryMutation,
} from '@desktop-client/budget';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSpreadsheet } from '@desktop-client/hooks/useSpreadsheet';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useDispatch } from '@desktop-client/redux';
export function Budget() {
const currentMonth = monthUtils.currentMonth();
const spreadsheet = useSpreadsheet();
const dispatch = useDispatch();
const navigate = useNavigate();
const [summaryCollapsed, setSummaryCollapsedPref] = useLocalPref(
'budget.summaryCollapsed',
);
@@ -58,8 +62,6 @@ export function Budget() {
const init = useEffectEvent(() => {
async function run() {
await dispatch(getCategories());
const { start, end } = await send('get-budget-bounds');
setBounds({ start, end });
@@ -118,35 +120,63 @@ export function Budget() {
}
};
const onApplyBudgetTemplatesInGroup = async categories => {
dispatch(
applyBudgetAction({
month: startMonth,
type: 'apply-multiple-templates',
args: {
categories,
},
}),
);
};
const onBudgetAction = (month, type, args) => {
dispatch(applyBudgetAction({ month, type, args }));
};
const onToggleCollapse = () => {
setSummaryCollapsedPref(!summaryCollapsed);
};
const {
onSaveCategory,
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onShowActivity,
onReorderCategory,
onReorderGroup,
} = useCategoryActions();
const onApplyBudgetTemplatesInGroup = async categories => {
applyBudgetAction.mutate({
month: startMonth,
type: 'apply-multiple-templates',
args: {
categories,
},
});
};
const onShowActivity = (categoryId, month) => {
const filterConditions = [
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
{
field: 'date',
op: 'is',
value: month,
options: { month: true },
type: 'date',
},
];
navigate('/accounts', {
state: {
goBack: true,
filterConditions,
categoryId,
},
});
};
const saveCategory = useSaveCategoryMutation();
const onSaveCategory = category => {
saveCategory.mutate({ category });
};
const deleteCategory = useDeleteCategoryMutation();
const onDeleteCategory = id => {
deleteCategory.mutate({ id });
};
const reorderCategory = useReorderCategoryMutation();
const saveCategoryGroup = useSaveCategoryGroupMutation();
const onSaveCategoryGroup = group => {
saveCategoryGroup.mutate({ group });
};
const deleteCategoryGroup = useDeleteCategoryGroupMutation();
const onDeleteCategoryGroup = id => {
deleteCategoryGroup.mutate({ id });
};
const reorderCategoryGroup = useReorderCategoryGroupMutation();
const applyBudgetAction = useBudgetActions();
const onBudgetAction = (month, type, args) => {
applyBudgetAction.mutate({ month, type, args });
};
if (!initialized || !categoryGroups) {
return null;
@@ -168,13 +198,13 @@ export function Budget() {
maxMonths={maxMonths}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
onDeleteGroup={onDeleteCategoryGroup}
onSaveCategory={onSaveCategory}
onSaveGroup={onSaveGroup}
onSaveGroup={onSaveCategoryGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onReorderCategory={onReorderCategory}
onReorderGroup={onReorderGroup}
onReorderCategory={reorderCategory.mutate}
onReorderGroup={reorderCategoryGroup.mutate}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
/>
</TrackingBudgetProvider>
@@ -194,13 +224,13 @@ export function Budget() {
maxMonths={maxMonths}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
onDeleteGroup={onDeleteCategoryGroup}
onSaveCategory={onSaveCategory}
onSaveGroup={onSaveGroup}
onSaveGroup={onSaveCategoryGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onReorderCategory={onReorderCategory}
onReorderGroup={onReorderGroup}
onReorderCategory={reorderCategory.mutate}
onReorderGroup={reorderCategoryGroup.mutate}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
/>
</EnvelopeBudgetProvider>

View File

@@ -36,14 +36,14 @@ import { BudgetTable, PILL_STYLE } from './BudgetTable';
import { sync } from '@desktop-client/app/appSlice';
import {
applyBudgetAction,
createCategory,
createCategoryGroup,
deleteCategory,
deleteCategoryGroup,
updateCategory,
updateCategoryGroup,
} from '@desktop-client/budget/budgetSlice';
useBudgetActions,
useCreateCategoryGroupMutation,
useCreateCategoryMutation,
useDeleteCategoryGroupMutation,
useDeleteCategoryMutation,
useSaveCategoryGroupMutation,
useSaveCategoryMutation,
} from '@desktop-client/budget';
import { closeBudget } from '@desktop-client/budgetfiles/budgetfilesSlice';
import { prewarmMonth } from '@desktop-client/components/budget/util';
import { FinancialText } from '@desktop-client/components/FinancialText';
@@ -91,6 +91,13 @@ export function BudgetPage() {
const numberFormat = _numberFormat || 'comma-dot';
const [hideFraction] = useSyncedPref('hideFraction');
const dispatch = useDispatch();
const applyBudgetAction = useBudgetActions();
const createCategory = useCreateCategoryMutation();
const saveCategory = useSaveCategoryMutation();
const deleteCategory = useDeleteCategoryMutation();
const createCategoryGroup = useCreateCategoryGroupMutation();
const saveCategoryGroup = useSaveCategoryGroupMutation();
const deleteCategoryGroup = useDeleteCategoryGroupMutation();
useEffect(() => {
async function init() {
@@ -107,9 +114,9 @@ export function BudgetPage() {
const onBudgetAction = useCallback(
async (month, type, args) => {
dispatch(applyBudgetAction({ month, type, args }));
applyBudgetAction.mutate({ month, type, args });
},
[dispatch],
[applyBudgetAction],
);
const onShowBudgetSummary = useCallback(() => {
@@ -147,14 +154,22 @@ export function BudgetPage() {
options: {
onValidate: name => (!name ? 'Name is required.' : null),
onSubmit: async name => {
dispatch(collapseModals({ rootModalName: 'budget-page-menu' }));
dispatch(createCategoryGroup({ name }));
createCategoryGroup.mutate(
{ name },
{
onSettled: () => {
dispatch(
collapseModals({ rootModalName: 'budget-page-menu' }),
);
},
},
);
},
},
},
}),
);
}, [dispatch]);
}, [dispatch, createCategoryGroup]);
const onOpenNewCategoryModal = useCallback(
(groupId, isIncome) => {
@@ -165,11 +180,22 @@ export function BudgetPage() {
options: {
onValidate: name => (!name ? 'Name is required.' : null),
onSubmit: async name => {
dispatch(
collapseModals({ rootModalName: 'category-group-menu' }),
);
dispatch(
createCategory({ name, groupId, isIncome, isHidden: false }),
createCategory.mutate(
{
name,
groupId,
isIncome,
isHidden: false,
},
{
onSettled: () => {
dispatch(
collapseModals({
rootModalName: 'category-group-menu',
}),
);
},
},
);
},
},
@@ -177,75 +203,41 @@ export function BudgetPage() {
}),
);
},
[dispatch],
[dispatch, createCategory],
);
const onSaveGroup = useCallback(
group => {
dispatch(updateCategoryGroup({ group }));
saveCategoryGroup.mutate({ group });
},
[dispatch],
[saveCategoryGroup],
);
const onApplyBudgetTemplatesInGroup = useCallback(
async categories => {
dispatch(
applyBudgetAction({
month: startMonth,
type: 'apply-multiple-templates',
args: {
categories,
},
}),
);
applyBudgetAction.mutate({
month: startMonth,
type: 'apply-multiple-templates',
args: {
categories,
},
});
},
[dispatch, startMonth],
[applyBudgetAction, startMonth],
);
const onDeleteGroup = useCallback(
async groupId => {
const group = categoryGroups?.find(g => g.id === groupId);
if (!group) {
return;
}
let mustTransfer = false;
for (const category of group.categories ?? []) {
if (await send('must-category-transfer', { id: category.id })) {
mustTransfer = true;
break;
}
}
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
group: groupId,
onDelete: transferCategory => {
dispatch(
collapseModals({ rootModalName: 'category-group-menu' }),
);
dispatch(
deleteCategoryGroup({
id: groupId,
transferId: transferCategory,
}),
);
},
},
},
}),
);
} else {
dispatch(collapseModals({ rootModalName: 'category-group-menu' }));
dispatch(deleteCategoryGroup({ id: groupId }));
}
groupId => {
deleteCategoryGroup.mutate(
{ id: groupId },
{
onSettled: () => {
dispatch(collapseModals({ rootModalName: 'category-group-menu' }));
},
},
);
},
[categoryGroups, dispatch],
[deleteCategoryGroup, dispatch],
);
const onToggleGroupVisibility = useCallback(
@@ -262,47 +254,23 @@ export function BudgetPage() {
const onSaveCategory = useCallback(
category => {
dispatch(updateCategory({ category }));
saveCategory.mutate({ category });
},
[dispatch],
[saveCategory],
);
const onDeleteCategory = useCallback(
async categoryId => {
const mustTransfer = await send('must-category-transfer', {
id: categoryId,
});
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
category: categoryId,
onDelete: transferCategory => {
if (categoryId !== transferCategory) {
dispatch(
collapseModals({ rootModalName: 'category-menu' }),
);
dispatch(
deleteCategory({
id: categoryId,
transferId: transferCategory,
}),
);
}
},
},
},
}),
);
} else {
dispatch(collapseModals({ rootModalName: 'category-menu' }));
dispatch(deleteCategory({ id: categoryId }));
}
categoryId => {
deleteCategory.mutate(
{ id: categoryId },
{
onSettled: () => {
dispatch(collapseModals({ rootModalName: 'category-menu' }));
},
},
);
},
[dispatch],
[deleteCategory, dispatch],
);
const onToggleCategoryVisibility = useCallback(

View File

@@ -12,8 +12,7 @@ import {
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
import { moveCategory } from '@desktop-client/budget/budgetSlice';
import { useDispatch } from '@desktop-client/redux';
import { useMoveCategoryMutation } from '@desktop-client/budget';
type ExpenseCategoryListProps = {
categoryGroup: CategoryGroupEntity;
@@ -37,7 +36,7 @@ export function ExpenseCategoryList({
shouldHideCategory,
}: ExpenseCategoryListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const moveCategory = useMoveCategoryMutation();
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
@@ -82,13 +81,11 @@ export function ExpenseCategoryList({
const targetCategoryId = e.target.key as CategoryEntity['id'];
if (e.target.dropPosition === 'before') {
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: categoryToMove.group,
targetId: targetCategoryId,
}),
);
moveCategory.mutate({
id: categoryToMove.id,
groupId: categoryToMove.group,
targetId: targetCategoryId,
});
} else if (e.target.dropPosition === 'after') {
const targetCategoryIndex = categories.findIndex(
c => c.id === targetCategoryId,
@@ -102,18 +99,16 @@ export function ExpenseCategoryList({
const nextToTargetCategory = categories[targetCategoryIndex + 1];
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: categoryToMove.group,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
}),
);
moveCategory.mutate({
id: categoryToMove.id,
groupId: categoryToMove.group,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
});
}
},
});

View File

@@ -15,8 +15,7 @@ import {
ExpenseGroupListItem,
} from './ExpenseGroupListItem';
import { moveCategoryGroup } from '@desktop-client/budget/budgetSlice';
import { useDispatch } from '@desktop-client/redux';
import { useMoveCategoryGroupMutation } from '@desktop-client/budget';
type ExpenseGroupListProps = {
categoryGroups: CategoryGroupEntity[];
@@ -44,7 +43,7 @@ export function ExpenseGroupList({
onToggleCollapse,
}: ExpenseGroupListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const moveCategoryGroup = useMoveCategoryGroupMutation();
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
@@ -104,12 +103,10 @@ export function ExpenseGroupList({
const targetGroupId = e.target.key as CategoryEntity['id'];
if (e.target.dropPosition === 'before') {
dispatch(
moveCategoryGroup({
id: groupToMove.id,
targetId: targetGroupId,
}),
);
moveCategoryGroup.mutate({
id: groupToMove.id,
targetId: targetGroupId,
});
} else if (e.target.dropPosition === 'after') {
const targetGroupIndex = categoryGroups.findIndex(
c => c.id === targetGroupId,
@@ -123,17 +120,15 @@ export function ExpenseGroupList({
const nextToTargetCategory = categoryGroups[targetGroupIndex + 1];
dispatch(
moveCategoryGroup({
id: groupToMove.id,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
}),
);
moveCategoryGroup.mutate({
id: groupToMove.id,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
});
}
},
});

View File

@@ -9,8 +9,7 @@ import { type CategoryEntity } from 'loot-core/types/models';
import { IncomeCategoryListItem } from './IncomeCategoryListItem';
import { moveCategory } from '@desktop-client/budget/budgetSlice';
import { useDispatch } from '@desktop-client/redux';
import { useMoveCategoryMutation } from '@desktop-client/budget';
type IncomeCategoryListProps = {
categories: CategoryEntity[];
@@ -26,7 +25,7 @@ export function IncomeCategoryList({
onBudgetAction,
}: IncomeCategoryListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const moveCategory = useMoveCategoryMutation();
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
@@ -71,13 +70,11 @@ export function IncomeCategoryList({
const targetCategoryId = e.target.key as CategoryEntity['id'];
if (e.target.dropPosition === 'before') {
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: categoryToMove.group,
targetId: targetCategoryId,
}),
);
moveCategory.mutate({
id: categoryToMove.id,
groupId: categoryToMove.group,
targetId: targetCategoryId,
});
} else if (e.target.dropPosition === 'after') {
const targetCategoryIndex = categories.findIndex(
c => c.id === targetCategoryId,
@@ -91,18 +88,16 @@ export function IncomeCategoryList({
const nextToTargetCategory = categories[targetCategoryIndex + 1];
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: categoryToMove.group,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
}),
);
moveCategory.mutate({
id: categoryToMove.id,
groupId: categoryToMove.group,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
});
}
},
});

View File

@@ -168,7 +168,7 @@ export function ImportTransactionsModal({
const dateFormat = useDateFormat() || ('MM/dd/yyyy' as const);
const [prefs, savePrefs] = useSyncedPrefs();
const dispatch = useDispatch();
const categories = useCategories();
const { list: categories } = useCategories();
const [multiplierAmount, setMultiplierAmount] = useState('');
const [loadingState, setLoadingState] = useState<
@@ -289,7 +289,7 @@ export function ImportTransactionsModal({
break;
}
const category_id = parseCategoryFields(trans, categories.list);
const category_id = parseCategoryFields(trans, categories);
if (category_id != null) {
trans.category = category_id;
}
@@ -350,7 +350,7 @@ export function ImportTransactionsModal({
// add the updated existing transaction in the list, with the
// isMatchedTransaction flag to identify it in display and not send it again
existing_trx.isMatchedTransaction = true;
existing_trx.category = categories.list.find(
existing_trx.category = categories.find(
cat => cat.id === existing_trx.category,
)?.name;
// add parent transaction attribute to mimic behaviour
@@ -365,7 +365,7 @@ export function ImportTransactionsModal({
return next;
}, []);
},
[accountId, categories.list, clearOnImport, dispatch],
[accountId, categories, clearOnImport, dispatch],
);
const parse = useCallback(
@@ -626,7 +626,7 @@ export function ImportTransactionsModal({
break;
}
const category_id = parseCategoryFields(trans, categories.list);
const category_id = parseCategoryFields(trans, categories);
trans.category = category_id;
const {
@@ -870,7 +870,7 @@ export function ImportTransactionsModal({
outValue={outValue}
flipAmount={flipAmount}
multiplierAmount={multiplierAmount}
categories={categories.list}
categories={categories}
onCheckTransaction={onCheckTransaction}
reconcile={reconcile}
/>

View File

@@ -42,6 +42,7 @@ import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { format as formatDate, parseISO } from 'date-fns';
import memoizeOne from 'memoize-one';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
@@ -86,7 +87,6 @@ import {
import { TransactionMenu } from './TransactionMenu';
import { getAccountsById } from '@desktop-client/accounts/accountsSlice';
import { getCategoriesById } from '@desktop-client/budget/budgetSlice';
import { AccountAutocomplete } from '@desktop-client/components/autocomplete/AccountAutocomplete';
import { CategoryAutocomplete } from '@desktop-client/components/autocomplete/CategoryAutocomplete';
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
@@ -3038,3 +3038,16 @@ export const TransactionTable = forwardRef(
);
TransactionTable.displayName = 'TransactionTable';
const getCategoriesById = memoizeOne(
(categoryGroups: CategoryGroupEntity[] | null | undefined) => {
const res: { [id: CategoryEntity['id']]: CategoryEntity } = {};
categoryGroups?.forEach(group => {
group.categories?.forEach(cat => {
res[cat.id] = cat;
});
});
return res;
},
);

View File

@@ -1,10 +1,12 @@
// @ts-strict-ignore
import { type QueryClient } from '@tanstack/react-query';
import { listen } from 'loot-core/platform/client/fetch';
import * as undo from 'loot-core/platform/client/undo';
import { reloadAccounts } from './accounts/accountsSlice';
import { setAppState } from './app/appSlice';
import { reloadCategories } from './budget/budgetSlice';
import { categoryQueries } from './budget';
import { closeBudgetUI } from './budgetfiles/budgetfilesSlice';
import { closeModal, pushModal, replaceModal } from './modals/modalsSlice';
import {
@@ -16,7 +18,7 @@ import { loadPrefs } from './prefs/prefsSlice';
import { type AppStore } from './redux/store';
import * as syncEvents from './sync-events';
export function handleGlobalEvents(store: AppStore) {
export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
const unlistenServerError = listen('server-error', () => {
store.dispatch(addGenericErrorNotification());
});
@@ -45,7 +47,7 @@ export function handleGlobalEvents(store: AppStore) {
);
});
const unlistenSync = syncEvents.listenForSyncEvent(store);
const unlistenSync = syncEvents.listenForSyncEvent(store, queryClient);
const unlistenUndo = listen('undo-event', undoState => {
const { tables, undoTag } = undoState;
@@ -56,7 +58,11 @@ export function handleGlobalEvents(store: AppStore) {
tables.includes('category_groups') ||
tables.includes('category_mapping')
) {
promises.push(store.dispatch(reloadCategories()));
promises.push(
queryClient.invalidateQueries({
queryKey: categoryQueries.lists(),
}),
);
}
if (

View File

@@ -1,22 +1,8 @@
import { useEffect } from 'react';
import { useInitialMount } from './useInitialMount';
import { getCategories } from '@desktop-client/budget/budgetSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { useCategoriesQuery } from './useCategoriesQuery';
export function useCategories() {
const dispatch = useDispatch();
const isInitialMount = useInitialMount();
const isCategoriesDirty = useSelector(
state => state.budget.isCategoriesDirty,
);
useEffect(() => {
if (isInitialMount || isCategoriesDirty) {
dispatch(getCategories());
}
}, [dispatch, isInitialMount, isCategoriesDirty]);
return useSelector(state => state.budget.categories);
const query = useCategoriesQuery();
// TODO: Update to return query states (e.g. isFetching, isError, etc)
// so clients can handle loading and error states appropriately.
return query.data ?? { list: [], grouped: [] };
}

View File

@@ -0,0 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { categoryQueries } from '@desktop-client/budget';
export function useCategoriesQuery() {
return useQuery(categoryQueries.list());
}

View File

@@ -1,8 +1,11 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCategories } from './useCategories';
import { categoryQueries } from '@desktop-client/budget';
export function useCategory(id: string) {
const { list: categories } = useCategories();
return useMemo(() => categories.find(c => c.id === id), [id, categories]);
const query = useQuery({
...categoryQueries.list(),
select: data => data.list.find(c => c.id === id),
});
return query.data;
}

View File

@@ -1,228 +0,0 @@
import { useTranslation } from 'react-i18next';
import { send } from 'loot-core/platform/client/fetch';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { useCategories } from './useCategories';
import { useNavigate } from './useNavigate';
import {
createCategory,
createCategoryGroup,
deleteCategory,
deleteCategoryGroup,
moveCategory,
moveCategoryGroup,
updateCategory,
updateCategoryGroup,
} from '@desktop-client/budget/budgetSlice';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function useCategoryActions() {
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const { grouped: categoryGroups } = useCategories();
const categoryNameAlreadyExistsNotification = (
name: CategoryEntity['name'],
) => {
dispatch(
addNotification({
notification: {
type: 'error',
message: t(
'Category "{{name}}" already exists in group (it may be hidden)',
{ name },
),
},
}),
);
};
const onSaveCategory = async (category: CategoryEntity) => {
const { grouped: categoryGroups = [] } = await send('get-categories');
const group = categoryGroups.find(g => g.id === category.group);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === category.name.toUpperCase())
.filter(c => (category.id === 'new' ? true : c.id !== category.id))
.length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(category.name);
return;
}
if (category.id === 'new') {
dispatch(
createCategory({
name: category.name,
groupId: category.group,
isIncome: !!category.is_income,
isHidden: !!category.hidden,
}),
);
} else {
dispatch(updateCategory({ category }));
}
};
const onDeleteCategory = async (id: CategoryEntity['id']) => {
const mustTransfer = await send('must-category-transfer', { id });
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
category: id,
onDelete: transferCategory => {
if (id !== transferCategory) {
dispatch(
deleteCategory({ id, transferId: transferCategory }),
);
}
},
},
},
}),
);
} else {
dispatch(deleteCategory({ id }));
}
};
const onSaveGroup = (group: CategoryGroupEntity) => {
if (group.id === 'new') {
dispatch(createCategoryGroup({ name: group.name }));
} else {
dispatch(updateCategoryGroup({ group }));
}
};
const onDeleteGroup = async (id: CategoryGroupEntity['id']) => {
const group = categoryGroups.find(g => g.id === id);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
let mustTransfer = false;
for (const category of groupCategories) {
if (await send('must-category-transfer', { id: category.id })) {
mustTransfer = true;
break;
}
}
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
group: id,
onDelete: transferCategory => {
dispatch(
deleteCategoryGroup({ id, transferId: transferCategory }),
);
},
},
},
}),
);
} else {
dispatch(deleteCategoryGroup({ id }));
}
};
const onShowActivity = (categoryId: CategoryEntity['id'], month: string) => {
const filterConditions = [
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
{
field: 'date',
op: 'is',
value: month,
options: { month: true },
type: 'date',
},
];
navigate('/accounts', {
state: {
goBack: true,
filterConditions,
categoryId,
},
});
};
const onReorderCategory = async (sortInfo: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
}) => {
const { grouped: categoryGroups = [], list: categories = [] } =
await send('get-categories');
const moveCandidate = categories.find(c => c.id === sortInfo.id);
const group = categoryGroups.find(g => g.id === sortInfo.groupId);
if (!moveCandidate || !group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === moveCandidate.name.toUpperCase())
.filter(c => c.id !== moveCandidate.id).length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(moveCandidate.name);
return;
}
dispatch(
moveCategory({
id: moveCandidate.id,
groupId: group.id,
targetId: sortInfo.targetId,
}),
);
};
const onReorderGroup = async (sortInfo: {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
}) => {
dispatch(
moveCategoryGroup({ id: sortInfo.id, targetId: sortInfo.targetId }),
);
};
return {
onSaveCategory,
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onShowActivity,
onReorderCategory,
onReorderGroup,
};
}

View File

@@ -1,11 +1,11 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCategories } from './useCategories';
import { categoryQueries } from '@desktop-client/budget';
export function useCategoryGroup(id: string) {
const { grouped: categoryGroups } = useCategories();
return useMemo(
() => categoryGroups.find(g => g.id === id),
[id, categoryGroups],
);
const query = useQuery({
...categoryQueries.list(),
select: data => data.grouped.find(g => g.id === id),
});
return query.data;
}

View File

@@ -10,6 +10,7 @@ import { Provider } from 'react-redux';
import { type NavigateFunction } from 'react-router';
import { bindActionCreators } from '@reduxjs/toolkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/fetch';
import { q } from 'loot-core/shared/query';
@@ -17,7 +18,6 @@ import { q } from 'loot-core/shared/query';
import * as accountsSlice from './accounts/accountsSlice';
import * as appSlice from './app/appSlice';
import { AuthProvider } from './auth/AuthProvider';
import * as budgetSlice from './budget/budgetSlice';
import * as budgetfilesSlice from './budgetfiles/budgetfilesSlice';
import { App } from './components/App';
import { ServerProvider } from './components/ServerContext';
@@ -36,7 +36,6 @@ const boundActions = bindActionCreators(
{
...accountsSlice.actions,
...appSlice.actions,
...budgetSlice.actions,
...budgetfilesSlice.actions,
...modalsSlice.actions,
...notificationsSlice.actions,
@@ -83,13 +82,18 @@ window.$send = send;
window.$query = aqlQuery;
window.$q = q;
const queryClient = new QueryClient();
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<Provider store={store}>
<ServerProvider>
<AuthProvider>
<App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</AuthProvider>
</ServerProvider>
</Provider>,
@@ -109,6 +113,8 @@ declare global {
$send: typeof send;
$query: typeof aqlQuery;
$q: typeof q;
__TANSTACK_QUERY_CLIENT__: QueryClient;
}
}

View File

@@ -13,10 +13,6 @@ import {
name as appSliceName,
reducer as appSliceReducer,
} from '@desktop-client/app/appSlice';
import {
name as budgetSliceName,
reducer as budgetSliceReducer,
} from '@desktop-client/budget/budgetSlice';
import {
name as budgetfilesSliceName,
reducer as budgetfilesSliceReducer,
@@ -53,7 +49,6 @@ import {
const appReducer = combineReducers({
[accountsSliceName]: accountsSliceReducer,
[appSliceName]: appSliceReducer,
[budgetSliceName]: budgetSliceReducer,
[budgetfilesSliceName]: budgetfilesSliceReducer,
[modalsSliceName]: modalsSliceReducer,
[notificationsSliceName]: notificationsSliceReducer,

View File

@@ -13,10 +13,6 @@ import {
name as appSliceName,
reducer as appSliceReducer,
} from '@desktop-client/app/appSlice';
import {
name as budgetSliceName,
reducer as budgetSliceReducer,
} from '@desktop-client/budget/budgetSlice';
import {
name as budgetfilesSliceName,
reducer as budgetfilesSliceReducer,
@@ -54,7 +50,6 @@ import {
const rootReducer = combineReducers({
[accountsSliceName]: accountsSliceReducer,
[appSliceName]: appSliceReducer,
[budgetSliceName]: budgetSliceReducer,
[budgetfilesSliceName]: budgetfilesSliceReducer,
[modalsSliceName]: modalsSliceReducer,
[notificationsSliceName]: notificationsSliceReducer,

View File

@@ -1,11 +1,12 @@
// @ts-strict-ignore
import { type QueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { listen, send } from 'loot-core/platform/client/fetch';
import { reloadAccounts } from './accounts/accountsSlice';
import { resetSync, sync } from './app/appSlice';
import { reloadCategories } from './budget/budgetSlice';
import { categoryQueries } from './budget';
import {
closeAndDownloadBudget,
uploadBudget,
@@ -20,7 +21,7 @@ import { loadPrefs } from './prefs/prefsSlice';
import { type AppStore } from './redux/store';
import { signOut } from './users/usersSlice';
export function listenForSyncEvent(store: AppStore) {
export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
// TODO: Should this run on mobile too?
const unlistenUnauthorized = listen('sync-event', async ({ type }) => {
if (type === 'unauthorized') {
@@ -72,7 +73,9 @@ export function listenForSyncEvent(store: AppStore) {
tables.includes('category_groups') ||
tables.includes('category_mapping')
) {
store.dispatch(reloadCategories());
queryClient.invalidateQueries({
queryKey: categoryQueries.lists(),
});
}
if (

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Move redux state to react query - category states

View File

@@ -155,6 +155,7 @@ __metadata:
"@rollup/plugin-inject": "npm:^5.0.5"
"@swc/core": "npm:^1.15.8"
"@swc/helpers": "npm:^0.5.18"
"@tanstack/react-query": "npm:^5.90.5"
"@testing-library/dom": "npm:10.4.1"
"@testing-library/jest-dom": "npm:^6.9.1"
"@testing-library/react": "npm:16.3.0"
@@ -8490,6 +8491,24 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/query-core@npm:5.90.7":
version: 5.90.7
resolution: "@tanstack/query-core@npm:5.90.7"
checksum: 10/bb2a2caf1558c09276ab1e30ad343b31f56ba9421bb4319c1d5dd36efcb95fc2823dbe1eb7982e7aa76c1fbe48b8fecac7e465206c8d16886f552129c487c288
languageName: node
linkType: hard
"@tanstack/react-query@npm:^5.90.5":
version: 5.90.7
resolution: "@tanstack/react-query@npm:5.90.7"
dependencies:
"@tanstack/query-core": "npm:5.90.7"
peerDependencies:
react: ^18 || ^19
checksum: 10/d1461cfcaad90678d81c2ec1b5ff92cd54d4a08fed7710d67b01825d697e58951530954fdd59f344511da96bf001b218b25af60141683d54e4e279c7b16d3c6a
languageName: node
linkType: hard
"@testing-library/dom@npm:10.4.1":
version: 10.4.1
resolution: "@testing-library/dom@npm:10.4.1"