mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
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:
committed by
GitHub
parent
c57260a504
commit
7f6f4d5def
@@ -119,7 +119,7 @@
|
||||
"react/exhaustive-deps": [
|
||||
"warn",
|
||||
{
|
||||
"additionalHooks": "(useQuery|useEffectAfterMount)"
|
||||
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-brace-presence": "warn",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
2
packages/desktop-client/src/budget/index.ts
Normal file
2
packages/desktop-client/src/budget/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './queries';
|
||||
export * from './mutations';
|
||||
843
packages/desktop-client/src/budget/mutations.ts
Normal file
843
packages/desktop-client/src/budget/mutations.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
65
packages/desktop-client/src/budget/queries.ts
Normal file
65
packages/desktop-client/src/budget/queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
7
packages/desktop-client/src/hooks/useCategoriesQuery.ts
Normal file
7
packages/desktop-client/src/hooks/useCategoriesQuery.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { categoryQueries } from '@desktop-client/budget';
|
||||
|
||||
export function useCategoriesQuery() {
|
||||
return useQuery(categoryQueries.list());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
6
upcoming-release-notes/5977.md
Normal file
6
upcoming-release-notes/5977.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Move redux state to react query - category states
|
||||
19
yarn.lock
19
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user