diff --git a/.oxlintrc.json b/.oxlintrc.json index bfe5fc51e1..65cd1532f3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -119,7 +119,7 @@ "react/exhaustive-deps": [ "warn", { - "additionalHooks": "(useQuery|useEffectAfterMount)" + "additionalHooks": "(^useQuery$|^useEffectAfterMount$)" } ], "react/jsx-curly-brace-presence": "warn", diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index dd33ee85db..5fc2b635c4 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -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", diff --git a/packages/desktop-client/src/budget/budgetSlice.ts b/packages/desktop-client/src/budget/budgetSlice.ts deleted file mode 100644 index 99eae90b1c..0000000000 --- a/packages/desktop-client/src/budget/budgetSlice.ts +++ /dev/null @@ -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; - }; - } - | { - 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; -} diff --git a/packages/desktop-client/src/budget/index.ts b/packages/desktop-client/src/budget/index.ts new file mode 100644 index 0000000000..d0720956a0 --- /dev/null +++ b/packages/desktop-client/src/budget/index.ts @@ -0,0 +1,2 @@ +export * from './queries'; +export * from './mutations'; diff --git a/packages/desktop-client/src/budget/mutations.ts b/packages/desktop-client/src/budget/mutations.ts new file mode 100644 index 0000000000..50a65f2d95 --- /dev/null +++ b/packages/desktop-client/src/budget/mutations.ts @@ -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; + }; + } + | { + 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; + }, + }); +} diff --git a/packages/desktop-client/src/budget/queries.ts b/packages/desktop-client/src/budget/queries.ts new file mode 100644 index 0000000000..4b5d33951d --- /dev/null +++ b/packages/desktop-client/src/budget/queries.ts @@ -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({ + 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, + }; +} diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 32c4845e8e..80a67c2c16 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -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(), diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx index 1f6412e75b..f994fc620f 100644 --- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx @@ -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) => diff --git a/packages/desktop-client/src/components/budget/BudgetTable.tsx b/packages/desktop-client/src/components/budget/BudgetTable.tsx index f32d99fbd8..f22aeba93b 100644 --- a/packages/desktop-client/src/components/budget/BudgetTable.tsx +++ b/packages/desktop-client/src/components/budget/BudgetTable.tsx @@ -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), - }); } }; diff --git a/packages/desktop-client/src/components/budget/index.tsx b/packages/desktop-client/src/components/budget/index.tsx index 7518a8fcdf..ce41a7f764 100644 --- a/packages/desktop-client/src/components/budget/index.tsx +++ b/packages/desktop-client/src/components/budget/index.tsx @@ -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} /> @@ -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} /> diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetPage.tsx b/packages/desktop-client/src/components/mobile/budget/BudgetPage.tsx index 1fe5e79378..01f0b3d070 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetPage.tsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetPage.tsx @@ -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( diff --git a/packages/desktop-client/src/components/mobile/budget/ExpenseCategoryList.tsx b/packages/desktop-client/src/components/mobile/budget/ExpenseCategoryList.tsx index a330587894..51aed36e60 100644 --- a/packages/desktop-client/src/components/mobile/budget/ExpenseCategoryList.tsx +++ b/packages/desktop-client/src/components/mobile/budget/ExpenseCategoryList.tsx @@ -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, + }); } }, }); diff --git a/packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx b/packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx index 455e9873c4..aa114db131 100644 --- a/packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx +++ b/packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx @@ -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, + }); } }, }); diff --git a/packages/desktop-client/src/components/mobile/budget/IncomeCategoryList.tsx b/packages/desktop-client/src/components/mobile/budget/IncomeCategoryList.tsx index 0960778881..7c97f23ef5 100644 --- a/packages/desktop-client/src/components/mobile/budget/IncomeCategoryList.tsx +++ b/packages/desktop-client/src/components/mobile/budget/IncomeCategoryList.tsx @@ -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, + }); } }, }); diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx index 01325c6c29..a179a49840 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx +++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx @@ -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} /> diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.tsx b/packages/desktop-client/src/components/transactions/TransactionsTable.tsx index e1f5caf019..3ef6e6057e 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.tsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.tsx @@ -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; + }, +); diff --git a/packages/desktop-client/src/global-events.ts b/packages/desktop-client/src/global-events.ts index d35f375b8f..6f31da02d1 100644 --- a/packages/desktop-client/src/global-events.ts +++ b/packages/desktop-client/src/global-events.ts @@ -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 ( diff --git a/packages/desktop-client/src/hooks/useCategories.ts b/packages/desktop-client/src/hooks/useCategories.ts index 918b5de291..c52bb4dfcd 100644 --- a/packages/desktop-client/src/hooks/useCategories.ts +++ b/packages/desktop-client/src/hooks/useCategories.ts @@ -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: [] }; } diff --git a/packages/desktop-client/src/hooks/useCategoriesQuery.ts b/packages/desktop-client/src/hooks/useCategoriesQuery.ts new file mode 100644 index 0000000000..816e8826dc --- /dev/null +++ b/packages/desktop-client/src/hooks/useCategoriesQuery.ts @@ -0,0 +1,7 @@ +import { useQuery } from '@tanstack/react-query'; + +import { categoryQueries } from '@desktop-client/budget'; + +export function useCategoriesQuery() { + return useQuery(categoryQueries.list()); +} diff --git a/packages/desktop-client/src/hooks/useCategory.ts b/packages/desktop-client/src/hooks/useCategory.ts index 028d504090..e2c35f64c2 100644 --- a/packages/desktop-client/src/hooks/useCategory.ts +++ b/packages/desktop-client/src/hooks/useCategory.ts @@ -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; } diff --git a/packages/desktop-client/src/hooks/useCategoryActions.ts b/packages/desktop-client/src/hooks/useCategoryActions.ts deleted file mode 100644 index 42e34468b4..0000000000 --- a/packages/desktop-client/src/hooks/useCategoryActions.ts +++ /dev/null @@ -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, - }; -} diff --git a/packages/desktop-client/src/hooks/useCategoryGroup.ts b/packages/desktop-client/src/hooks/useCategoryGroup.ts index 53806fe3cf..043367d941 100644 --- a/packages/desktop-client/src/hooks/useCategoryGroup.ts +++ b/packages/desktop-client/src/hooks/useCategoryGroup.ts @@ -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; } diff --git a/packages/desktop-client/src/index.tsx b/packages/desktop-client/src/index.tsx index 030aea09d3..2ff9ce9b50 100644 --- a/packages/desktop-client/src/index.tsx +++ b/packages/desktop-client/src/index.tsx @@ -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( - + + + , @@ -109,6 +113,8 @@ declare global { $send: typeof send; $query: typeof aqlQuery; $q: typeof q; + + __TANSTACK_QUERY_CLIENT__: QueryClient; } } diff --git a/packages/desktop-client/src/redux/mock.tsx b/packages/desktop-client/src/redux/mock.tsx index 0a56e90994..8d0d247552 100644 --- a/packages/desktop-client/src/redux/mock.tsx +++ b/packages/desktop-client/src/redux/mock.tsx @@ -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, diff --git a/packages/desktop-client/src/redux/store.ts b/packages/desktop-client/src/redux/store.ts index 712cd7a2cc..22cbd049fa 100644 --- a/packages/desktop-client/src/redux/store.ts +++ b/packages/desktop-client/src/redux/store.ts @@ -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, diff --git a/packages/desktop-client/src/sync-events.ts b/packages/desktop-client/src/sync-events.ts index b07a83e452..74ee8e406a 100644 --- a/packages/desktop-client/src/sync-events.ts +++ b/packages/desktop-client/src/sync-events.ts @@ -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 ( diff --git a/upcoming-release-notes/5977.md b/upcoming-release-notes/5977.md new file mode 100644 index 0000000000..ce3b963340 --- /dev/null +++ b/upcoming-release-notes/5977.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Move redux state to react query - category states diff --git a/yarn.lock b/yarn.lock index b9ca7c49c0..c740fbc2a2 100644 --- a/yarn.lock +++ b/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"