From 465608c76b1b6354be052d652089b2d675c2be4e Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Sat, 14 Feb 2026 01:32:37 -0800 Subject: [PATCH] Move redux state to react-query - account states (#6140) * Fix typecheck errors * Move redux state to react-query - account states * TestProviders * Add release notes for PR #6140 * Fix lint error * Fix TestProviders * Coderabbit feedback * Cleanup * [autofix.ci] apply automated fixes * Fix TestProviders * Fix onbudget and offbudget displaying closed accounts * [skip ci] Change category to Maintenance and update migration text * Replace logger calls in desktop-client to console * Fix lint errors * Clear react query on closing of budget file similar to redux resetApp action * [autofix.ci] apply automated fixes * Remove sendThrow * Code review feedback * [autofix.ci] apply automated fixes * Fix import * Fix import * Coderabbit feedback --------- Co-authored-by: github-actions[bot] Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../src/accounts/accountsSlice.ts | 582 +------------- packages/desktop-client/src/accounts/index.ts | 2 + .../desktop-client/src/accounts/mutations.ts | 760 ++++++++++++++++++ .../desktop-client/src/accounts/queries.ts | 46 ++ packages/desktop-client/src/app/appSlice.ts | 39 - .../desktop-client/src/budget/mutations.ts | 37 +- .../src/components/FinancesApp.tsx | 9 +- .../src/components/accounts/Account.tsx | 51 +- .../components/accounts/AccountSyncCheck.tsx | 7 +- .../components/banksync/EditSyncAccount.tsx | 11 +- .../mobile/accounts/AccountPage.tsx | 17 +- .../mobile/accounts/AccountTransactions.tsx | 7 +- .../mobile/accounts/AccountsPage.tsx | 81 +- .../MobileBankSyncAccountEditPage.tsx | 11 +- .../components/modals/CloseAccountModal.tsx | 27 +- .../modals/CreateLocalAccountModal.tsx | 20 +- .../ImportTransactionsModal.tsx | 152 ++-- .../modals/SelectLinkedAccountsModal.tsx | 109 ++- .../src/components/sidebar/Account.tsx | 24 +- .../src/components/sidebar/Accounts.tsx | 9 +- packages/desktop-client/src/global-events.ts | 8 +- .../desktop-client/src/hooks/useAccount.ts | 11 +- .../desktop-client/src/hooks/useAccounts.ts | 22 +- .../src/hooks/useClosedAccounts.ts | 13 +- .../src/hooks/useOffBudgetAccounts.ts | 14 +- .../src/hooks/useOnBudgetAccounts.ts | 14 +- packages/desktop-client/src/mocks.tsx | 1 + .../desktop-client/src/modals/modalsSlice.ts | 12 +- packages/desktop-client/src/sync-events.ts | 6 +- packages/loot-core/src/mocks/index.ts | 2 +- packages/loot-core/src/server/accounts/app.ts | 27 +- packages/loot-core/src/server/api.ts | 3 +- .../loot-core/src/server/db/types/index.ts | 2 + .../loot-core/src/types/models/account.ts | 2 +- upcoming-release-notes/6140.md | 6 + 35 files changed, 1199 insertions(+), 945 deletions(-) create mode 100644 packages/desktop-client/src/accounts/index.ts create mode 100644 packages/desktop-client/src/accounts/mutations.ts create mode 100644 packages/desktop-client/src/accounts/queries.ts create mode 100644 upcoming-release-notes/6140.md diff --git a/packages/desktop-client/src/accounts/accountsSlice.ts b/packages/desktop-client/src/accounts/accountsSlice.ts index 607a0e1817..0db058a65b 100644 --- a/packages/desktop-client/src/accounts/accountsSlice.ts +++ b/packages/desktop-client/src/accounts/accountsSlice.ts @@ -2,24 +2,10 @@ import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; import memoizeOne from 'memoize-one'; -import { send } from 'loot-core/platform/client/connection'; -import type { SyncResponseWithErrors } from 'loot-core/server/accounts/app'; import { groupById } from 'loot-core/shared/util'; -import type { - AccountEntity, - CategoryEntity, - SyncServerGoCardlessAccount, - SyncServerPluggyAiAccount, - SyncServerSimpleFinAccount, - TransactionEntity, -} from 'loot-core/types/models'; +import type { AccountEntity } from 'loot-core/types/models'; import { resetApp } from '@desktop-client/app/appSlice'; -import { addNotification } from '@desktop-client/notifications/notificationsSlice'; -import { markPayeesDirty } from '@desktop-client/payees/payeesSlice'; -import { createAppAsyncThunk } from '@desktop-client/redux'; -import type { AppDispatch } from '@desktop-client/redux/store'; -import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice'; const sliceName = 'account'; @@ -29,20 +15,12 @@ type AccountState = { }; accountsSyncing: Array; updatedAccounts: Array; - accounts: AccountEntity[]; - isAccountsLoading: boolean; - isAccountsLoaded: boolean; - isAccountsDirty: boolean; }; const initialState: AccountState = { failedAccounts: {}, accountsSyncing: [], updatedAccounts: [], - accounts: [], - isAccountsLoading: false, - isAccountsLoaded: false, - isAccountsDirty: false, }; type SetAccountsSyncingPayload = { @@ -102,542 +80,11 @@ const accountsSlice = createSlice({ id => id !== action.payload.id, ); }, - markAccountsDirty(state) { - _markAccountsDirty(state); - }, }, extraReducers: builder => { builder.addCase(resetApp, () => initialState); - - builder.addCase(createAccount.fulfilled, _markAccountsDirty); - builder.addCase(updateAccount.fulfilled, _markAccountsDirty); - builder.addCase(closeAccount.fulfilled, _markAccountsDirty); - builder.addCase(reopenAccount.fulfilled, _markAccountsDirty); - - builder.addCase(reloadAccounts.fulfilled, (state, action) => { - _loadAccounts(state, action.payload); - }); - - builder.addCase(reloadAccounts.rejected, state => { - state.isAccountsLoading = false; - }); - - builder.addCase(reloadAccounts.pending, state => { - state.isAccountsLoading = true; - }); - - builder.addCase(getAccounts.fulfilled, (state, action) => { - _loadAccounts(state, action.payload); - }); - - builder.addCase(getAccounts.rejected, state => { - state.isAccountsLoading = false; - }); - - builder.addCase(getAccounts.pending, state => { - state.isAccountsLoading = true; - }); }, }); -type CreateAccountPayload = { - name: string; - balance: number; - offBudget: boolean; -}; - -export const createAccount = createAppAsyncThunk( - `${sliceName}/createAccount`, - async ({ name, balance, offBudget }: CreateAccountPayload) => { - const id = await send('account-create', { - name, - balance, - offBudget, - }); - return id; - }, -); - -type CloseAccountPayload = { - id: AccountEntity['id']; - transferAccountId?: AccountEntity['id']; - categoryId?: CategoryEntity['id']; - forced?: boolean; -}; - -export const closeAccount = createAppAsyncThunk( - `${sliceName}/closeAccount`, - async ({ - id, - transferAccountId, - categoryId, - forced, - }: CloseAccountPayload) => { - await send('account-close', { - id, - transferAccountId: transferAccountId || undefined, - categoryId: categoryId || undefined, - forced, - }); - }, -); - -type ReopenAccountPayload = { - id: AccountEntity['id']; -}; - -export const reopenAccount = createAppAsyncThunk( - `${sliceName}/reopenAccount`, - async ({ id }: ReopenAccountPayload) => { - await send('account-reopen', { id }); - }, -); - -type UpdateAccountPayload = { - account: AccountEntity; -}; - -export const updateAccount = createAppAsyncThunk( - `${sliceName}/updateAccount`, - async ({ account }: UpdateAccountPayload) => { - await send('account-update', account); - return account; - }, -); - -export const getAccounts = createAppAsyncThunk( - `${sliceName}/getAccounts`, - async () => { - // TODO: Force cast to AccountEntity. - // Server is currently returning the DB model it should return the entity model instead. - const accounts = (await send('accounts-get')) as unknown as AccountEntity[]; - return accounts; - }, - { - condition: (_, { getState }) => { - const { account } = getState(); - return ( - !account.isAccountsLoading && - (account.isAccountsDirty || !account.isAccountsLoaded) - ); - }, - }, -); - -export const reloadAccounts = createAppAsyncThunk( - `${sliceName}/reloadAccounts`, - async () => { - // TODO: Force cast to AccountEntity. - // Server is currently returning the DB model it should return the entity model instead. - const accounts = (await send('accounts-get')) as unknown as AccountEntity[]; - return accounts; - }, -); - -type UnlinkAccountPayload = { - id: AccountEntity['id']; -}; - -export const unlinkAccount = createAppAsyncThunk( - `${sliceName}/unlinkAccount`, - async ({ id }: UnlinkAccountPayload, { dispatch }) => { - await send('account-unlink', { id }); - dispatch(actions.markAccountSuccess({ id })); - dispatch(actions.markAccountsDirty()); - }, -); - -// Shared base type for link account payloads -type LinkAccountBasePayload = { - upgradingId?: AccountEntity['id']; - offBudget?: boolean; - startingDate?: string; - startingBalance?: number; -}; - -type LinkAccountPayload = LinkAccountBasePayload & { - requisitionId: string; - account: SyncServerGoCardlessAccount; -}; - -export const linkAccount = createAppAsyncThunk( - `${sliceName}/linkAccount`, - async ( - { - requisitionId, - account, - upgradingId, - offBudget, - startingDate, - startingBalance, - }: LinkAccountPayload, - { dispatch }, - ) => { - await send('gocardless-accounts-link', { - requisitionId, - account, - upgradingId, - offBudget, - startingDate, - startingBalance, - }); - dispatch(markPayeesDirty()); - dispatch(markAccountsDirty()); - }, -); - -type LinkAccountSimpleFinPayload = LinkAccountBasePayload & { - externalAccount: SyncServerSimpleFinAccount; -}; - -export const linkAccountSimpleFin = createAppAsyncThunk( - `${sliceName}/linkAccountSimpleFin`, - async ( - { - externalAccount, - upgradingId, - offBudget, - startingDate, - startingBalance, - }: LinkAccountSimpleFinPayload, - { dispatch }, - ) => { - await send('simplefin-accounts-link', { - externalAccount, - upgradingId, - offBudget, - startingDate, - startingBalance, - }); - dispatch(markPayeesDirty()); - dispatch(markAccountsDirty()); - }, -); - -type LinkAccountPluggyAiPayload = LinkAccountBasePayload & { - externalAccount: SyncServerPluggyAiAccount; -}; - -export const linkAccountPluggyAi = createAppAsyncThunk( - `${sliceName}/linkAccountPluggyAi`, - async ( - { - externalAccount, - upgradingId, - offBudget, - startingDate, - startingBalance, - }: LinkAccountPluggyAiPayload, - { dispatch }, - ) => { - await send('pluggyai-accounts-link', { - externalAccount, - upgradingId, - offBudget, - startingDate, - startingBalance, - }); - dispatch(markPayeesDirty()); - dispatch(markAccountsDirty()); - }, -); - -function handleSyncResponse( - accountId: AccountEntity['id'], - res: SyncResponseWithErrors, - dispatch: AppDispatch, - resNewTransactions: Array, - resMatchedTransactions: Array, - resUpdatedAccounts: Array, -) { - const { errors, newTransactions, matchedTransactions, updatedAccounts } = res; - const { markAccountFailed, markAccountSuccess } = accountsSlice.actions; - - // Mark the account as failed or succeeded (depending on sync output) - const [error] = errors; - if (error) { - // We only want to mark the account as having problem if it - // was a real syncing error. - if ('type' in error && error.type === 'SyncError') { - dispatch( - markAccountFailed({ - id: accountId, - errorType: error.category, - errorCode: error.code, - }), - ); - } - } else { - dispatch(markAccountSuccess({ id: accountId })); - } - - // Dispatch errors (if any) - errors.forEach(error => { - if ('type' in error && error.type === 'SyncError') { - dispatch( - addNotification({ - notification: { - type: 'error', - message: error.message, - }, - }), - ); - } else { - dispatch( - addNotification({ - notification: { - type: 'error', - message: error.message, - internal: 'internal' in error ? error.internal : undefined, - }, - }), - ); - } - }); - - resNewTransactions.push(...newTransactions); - resMatchedTransactions.push(...matchedTransactions); - resUpdatedAccounts.push(...updatedAccounts); - - dispatch(markAccountsDirty()); - - return newTransactions.length > 0 || matchedTransactions.length > 0; -} - -type SyncAccountsPayload = { - id?: AccountEntity['id'] | undefined; -}; - -export const syncAccounts = createAppAsyncThunk( - `${sliceName}/syncAccounts`, - async ({ id }: SyncAccountsPayload, { dispatch, getState }) => { - // Disallow two parallel sync operations - const accountsState = getState().account; - if (accountsState.accountsSyncing.length > 0) { - return false; - } - - const { setAccountsSyncing } = accountsSlice.actions; - - if (id === 'uncategorized') { - // Sync no accounts - dispatch(setAccountsSyncing({ ids: [] })); - return false; - } - - const { accounts } = getState().account; - let accountIdsToSync: string[]; - if (id === 'offbudget' || id === 'onbudget') { - const targetOffbudget = id === 'offbudget' ? 1 : 0; - accountIdsToSync = accounts - .filter( - ({ bank, closed, tombstone, offbudget }) => - !!bank && !closed && !tombstone && offbudget === targetOffbudget, - ) - .sort((a, b) => a.sort_order - b.sort_order) - .map(({ id }) => id); - } else if (id) { - accountIdsToSync = [id]; - } else { - // Default: all accounts - accountIdsToSync = accounts - .filter( - ({ bank, closed, tombstone }) => !!bank && !closed && !tombstone, - ) - .sort((a, b) => - a.offbudget === b.offbudget - ? a.sort_order - b.sort_order - : a.offbudget - b.offbudget, - ) - .map(({ id }) => id); - } - - dispatch(setAccountsSyncing({ ids: accountIdsToSync })); - - // TODO: Force cast to AccountEntity. - // Server is currently returning the DB model it should return the entity model instead. - const accountsData = (await send( - 'accounts-get', - )) as unknown as AccountEntity[]; - const simpleFinAccounts = accountsData.filter( - a => - a.account_sync_source === 'simpleFin' && - accountIdsToSync.includes(a.id), - ); - - let isSyncSuccess = false; - const newTransactions: Array = []; - const matchedTransactions: Array = []; - const updatedAccounts: Array = []; - - if (simpleFinAccounts.length > 0) { - console.log('Using SimpleFin batch sync'); - - const res = await send('simplefin-batch-sync', { - ids: simpleFinAccounts.map(a => a.id), - }); - - for (const account of res) { - const success = handleSyncResponse( - account.accountId, - account.res, - dispatch, - newTransactions, - matchedTransactions, - updatedAccounts, - ); - if (success) isSyncSuccess = true; - } - - accountIdsToSync = accountIdsToSync.filter( - id => !simpleFinAccounts.find(sfa => sfa.id === id), - ); - } - - // Loop through the accounts and perform sync operation.. one by one - for (let idx = 0; idx < accountIdsToSync.length; idx++) { - const accountId = accountIdsToSync[idx]; - - // Perform sync operation - const res = await send('accounts-bank-sync', { - ids: [accountId], - }); - - const success = handleSyncResponse( - accountId, - res, - dispatch, - newTransactions, - matchedTransactions, - updatedAccounts, - ); - - if (success) isSyncSuccess = true; - - // Dispatch the ids for the accounts that are yet to be synced - dispatch(setAccountsSyncing({ ids: accountIdsToSync.slice(idx + 1) })); - } - - // Set new transactions - dispatch( - setNewTransactions({ - newTransactions, - matchedTransactions, - }), - ); - - dispatch(markUpdatedAccounts({ ids: updatedAccounts })); - - // Reset the sync state back to empty (fallback in case something breaks - // in the logic above) - dispatch(setAccountsSyncing({ ids: [] })); - return isSyncSuccess; - }, -); - -type MoveAccountPayload = { - id: AccountEntity['id']; - targetId: AccountEntity['id'] | null; -}; - -export const moveAccount = createAppAsyncThunk( - `${sliceName}/moveAccount`, - async ({ id, targetId }: MoveAccountPayload, { dispatch }) => { - await send('account-move', { id, targetId }); - dispatch(markAccountsDirty()); - dispatch(markPayeesDirty()); - }, -); - -type ImportPreviewTransactionsPayload = { - accountId: string; - transactions: TransactionEntity[]; -}; - -export const importPreviewTransactions = createAppAsyncThunk( - `${sliceName}/importPreviewTransactions`, - async ( - { accountId, transactions }: ImportPreviewTransactionsPayload, - { dispatch }, - ) => { - const { errors = [], updatedPreview } = await send('transactions-import', { - accountId, - transactions, - isPreview: true, - }); - - errors.forEach(error => { - dispatch( - addNotification({ - notification: { - type: 'error', - message: error.message, - }, - }), - ); - }); - - return updatedPreview; - }, -); - -type ImportTransactionsPayload = { - accountId: string; - transactions: TransactionEntity[]; - reconcile: boolean; -}; - -export const importTransactions = createAppAsyncThunk( - `${sliceName}/importTransactions`, - async ( - { accountId, transactions, reconcile }: ImportTransactionsPayload, - { dispatch }, - ) => { - if (!reconcile) { - await send('api/transactions-add', { - accountId, - transactions, - }); - - return true; - } - - const { - errors = [], - added, - updated, - } = await send('transactions-import', { - accountId, - transactions, - isPreview: false, - }); - - errors.forEach(error => { - dispatch( - addNotification({ - notification: { - type: 'error', - message: error.message, - }, - }), - ); - }); - - dispatch( - setNewTransactions({ - newTransactions: added, - matchedTransactions: updated, - }), - ); - - dispatch( - markUpdatedAccounts({ - ids: added.length > 0 ? [accountId] : [], - }), - ); - - return added.length > 0 || updated.length > 0; - }, -); export const getAccountsById = memoizeOne( (accounts: AccountEntity[] | null | undefined) => groupById(accounts), @@ -646,39 +93,12 @@ export const getAccountsById = memoizeOne( export const { name, reducer, getInitialState } = accountsSlice; export const actions = { ...accountsSlice.actions, - createAccount, - updateAccount, - getAccounts, - reloadAccounts, - closeAccount, - reopenAccount, - linkAccount, - linkAccountSimpleFin, - linkAccountPluggyAi, - moveAccount, - unlinkAccount, - syncAccounts, }; export const { markAccountRead, markAccountFailed, markAccountSuccess, - markAccountsDirty, markUpdatedAccounts, setAccountsSyncing, } = accountsSlice.actions; - -function _loadAccounts( - state: AccountState, - accounts: AccountState['accounts'], -) { - state.accounts = accounts; - state.isAccountsLoading = false; - state.isAccountsLoaded = true; - state.isAccountsDirty = false; -} - -function _markAccountsDirty(state: AccountState) { - state.isAccountsDirty = true; -} diff --git a/packages/desktop-client/src/accounts/index.ts b/packages/desktop-client/src/accounts/index.ts new file mode 100644 index 0000000000..d0720956a0 --- /dev/null +++ b/packages/desktop-client/src/accounts/index.ts @@ -0,0 +1,2 @@ +export * from './queries'; +export * from './mutations'; diff --git a/packages/desktop-client/src/accounts/mutations.ts b/packages/desktop-client/src/accounts/mutations.ts new file mode 100644 index 0000000000..be75f7f5e0 --- /dev/null +++ b/packages/desktop-client/src/accounts/mutations.ts @@ -0,0 +1,760 @@ +import { useTranslation } from 'react-i18next'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { QueryClient, QueryKey } from '@tanstack/react-query'; +import { v4 as uuidv4 } from 'uuid'; + +import { send } from 'loot-core/platform/client/connection'; +import type { SyncResponseWithErrors } from 'loot-core/server/accounts/app'; +import type { + AccountEntity, + CategoryEntity, + SyncServerGoCardlessAccount, + SyncServerPluggyAiAccount, + SyncServerSimpleFinAccount, + TransactionEntity, +} from 'loot-core/types/models'; + +import { + markAccountFailed, + markAccountSuccess, + markUpdatedAccounts, + setAccountsSyncing, +} from './accountsSlice'; +import { accountQueries } from './queries'; + +import { sync } from '@desktop-client/app/appSlice'; +import { useAccounts } from '@desktop-client/hooks/useAccounts'; +import { addNotification } from '@desktop-client/notifications/notificationsSlice'; +import { markPayeesDirty } from '@desktop-client/payees/payeesSlice'; +import { useDispatch, useStore } from '@desktop-client/redux'; +import type { AppDispatch } from '@desktop-client/redux/store'; +import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice'; + +const invalidateQueries = (queryClient: QueryClient, queryKey?: QueryKey) => { + queryClient.invalidateQueries({ + queryKey: queryKey ?? accountQueries.lists(), + }); +}; + +const dispatchErrorNotification = ( + dispatch: AppDispatch, + message: string, + error?: Error, +) => { + dispatch( + addNotification({ + notification: { + id: uuidv4(), + type: 'error', + message, + pre: error ? error.message : undefined, + }, + }), + ); +}; + +type CreateAccountPayload = { + name: string; + balance: number; + offBudget: boolean; +}; + +export function useCreateAccountMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ name, balance, offBudget }: CreateAccountPayload) => { + const id = await send('account-create', { + name, + balance, + offBudget, + }); + return id; + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error creating account:', error); + dispatchErrorNotification( + dispatch, + t('There was an error creating the account. Please try again.'), + error, + ); + }, + }); +} + +type CloseAccountPayload = { + id: AccountEntity['id']; + transferAccountId?: AccountEntity['id']; + categoryId?: CategoryEntity['id']; + forced?: boolean; +}; + +export function useCloseAccountMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + id, + transferAccountId, + categoryId, + forced, + }: CloseAccountPayload) => { + await send('account-close', { + id, + transferAccountId: transferAccountId || undefined, + categoryId: categoryId || undefined, + forced, + }); + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error closing account:', error); + dispatchErrorNotification( + dispatch, + t('There was an error closing the account. Please try again.'), + error, + ); + }, + }); +} + +type ReopenAccountPayload = { + id: AccountEntity['id']; +}; + +export function useReopenAccountMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ id }: ReopenAccountPayload) => { + await send('account-reopen', { id }); + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error re-opening account:', error); + dispatchErrorNotification( + dispatch, + t('There was an error re-opening the account. Please try again.'), + error, + ); + }, + }); +} + +type UpdateAccountPayload = { + account: AccountEntity; +}; + +export function useUpdateAccountMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ account }: UpdateAccountPayload) => { + await send('account-update', account); + return account; + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error updating account:', error); + dispatchErrorNotification( + dispatch, + t('There was an error updating the account. Please try again.'), + error, + ); + }, + }); +} + +type MoveAccountPayload = { + id: AccountEntity['id']; + targetId: AccountEntity['id'] | null; +}; + +export function useMoveAccountMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ id, targetId }: MoveAccountPayload) => { + await send('account-move', { id, targetId }); + }, + onSuccess: () => { + invalidateQueries(queryClient); + // TODO: Change to a call to queryClient.invalidateQueries + // once payees have been moved to react-query. + dispatch(markPayeesDirty()); + }, + onError: error => { + console.error('Error moving account:', error); + dispatchErrorNotification( + dispatch, + t('There was an error moving the account. Please try again.'), + error, + ); + }, + }); +} + +type ImportPreviewTransactionsPayload = { + accountId: string; + transactions: TransactionEntity[]; +}; + +export function useImportPreviewTransactionsMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + accountId, + transactions, + }: ImportPreviewTransactionsPayload) => { + const { errors = [], updatedPreview } = await send( + 'transactions-import', + { + accountId, + transactions, + isPreview: true, + }, + ); + + errors.forEach(error => { + dispatch( + addNotification({ + notification: { + type: 'error', + message: error.message, + }, + }), + ); + }); + + return updatedPreview; + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error importing preview transactions to account:', error); + dispatchErrorNotification( + dispatch, + t( + 'There was an error importing preview transactions to the account. Please try again.', + ), + error, + ); + }, + }); +} + +type ImportTransactionsPayload = { + accountId: string; + transactions: TransactionEntity[]; + reconcile: boolean; +}; + +export function useImportTransactionsMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + accountId, + transactions, + reconcile, + }: ImportTransactionsPayload) => { + if (!reconcile) { + await send('api/transactions-add', { + accountId, + transactions, + }); + + return true; + } + + const { + errors = [], + added, + updated, + } = await send('transactions-import', { + accountId, + transactions, + isPreview: false, + }); + + errors.forEach(error => { + dispatch( + addNotification({ + notification: { + type: 'error', + message: error.message, + }, + }), + ); + }); + + dispatch( + setNewTransactions({ + newTransactions: added, + matchedTransactions: updated, + }), + ); + + dispatch( + markUpdatedAccounts({ + ids: added.length > 0 ? [accountId] : [], + }), + ); + + return added.length > 0 || updated.length > 0; + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error importing transactions to account:', error); + dispatchErrorNotification( + dispatch, + t( + 'There was an error importing transactions to the account. Please try again.', + ), + error, + ); + }, + }); +} + +type UnlinkAccountPayload = { + id: AccountEntity['id']; +}; + +export function useUnlinkAccountMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ id }: UnlinkAccountPayload) => { + await send('account-unlink', { id }); + }, + onSuccess: (_, { id }) => { + invalidateQueries(queryClient); + dispatch(markAccountSuccess({ id })); + }, + onError: error => { + console.error('Error unlinking account:', error); + dispatchErrorNotification( + dispatch, + t('There was an error unlinking the account. Please try again.'), + error, + ); + }, + }); +} + +// Shared base type for link account payloads +type LinkAccountBasePayload = { + upgradingId?: AccountEntity['id']; + offBudget?: boolean; + startingDate?: string; + startingBalance?: number; +}; + +type LinkAccountPayload = LinkAccountBasePayload & { + requisitionId: string; + account: SyncServerGoCardlessAccount; +}; + +export function useLinkAccountMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + requisitionId, + account, + upgradingId, + offBudget, + startingDate, + startingBalance, + }: LinkAccountPayload) => { + await send('gocardless-accounts-link', { + requisitionId, + account, + upgradingId, + offBudget, + startingDate, + startingBalance, + }); + }, + onSuccess: () => { + invalidateQueries(queryClient); + // TODO: Change to a call to queryClient.invalidateQueries + // once payees have been moved to react-query. + dispatch(markPayeesDirty()); + }, + onError: error => { + console.error('Error linking account:', error); + dispatchErrorNotification( + dispatch, + t('There was an error linking the account. Please try again.'), + error, + ); + }, + }); +} + +type LinkAccountSimpleFinPayload = LinkAccountBasePayload & { + externalAccount: SyncServerSimpleFinAccount; +}; + +export function useLinkAccountSimpleFinMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + externalAccount, + upgradingId, + offBudget, + startingDate, + startingBalance, + }: LinkAccountSimpleFinPayload) => { + await send('simplefin-accounts-link', { + externalAccount, + upgradingId, + offBudget, + startingDate, + startingBalance, + }); + }, + onSuccess: () => { + invalidateQueries(queryClient); + // TODO: Change to a call to queryClient.invalidateQueries + // once payees have been moved to react-query. + dispatch(markPayeesDirty()); + }, + onError: error => { + console.error('Error linking account to SimpleFIN:', error); + dispatchErrorNotification( + dispatch, + t( + 'There was an error linking the account to SimpleFIN. Please try again.', + ), + error, + ); + }, + }); +} + +type LinkAccountPluggyAiPayload = LinkAccountBasePayload & { + externalAccount: SyncServerPluggyAiAccount; +}; + +export function useLinkAccountPluggyAiMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + externalAccount, + upgradingId, + offBudget, + startingDate, + startingBalance, + }: LinkAccountPluggyAiPayload) => { + await send('pluggyai-accounts-link', { + externalAccount, + upgradingId, + offBudget, + startingDate, + startingBalance, + }); + }, + onSuccess: () => { + invalidateQueries(queryClient); + // TODO: Change to a call to queryClient.invalidateQueries + // once payees have been moved to react-query. + dispatch(markPayeesDirty()); + }, + onError: error => { + console.error('Error linking account to PluggyAI:', error); + dispatchErrorNotification( + dispatch, + t( + 'There was an error linking the account to PluggyAI. Please try again.', + ), + error, + ); + }, + }); +} + +type SyncAccountsPayload = { + id?: AccountEntity['id'] | undefined; +}; + +export function useSyncAccountsMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const accounts = useAccounts(); + const store = useStore(); + + return useMutation({ + mutationFn: async ({ id }: SyncAccountsPayload) => { + const { + account: { accountsSyncing = [] }, + } = store.getState(); + + // Disallow two parallel sync operations + if (accountsSyncing.length > 0) { + return false; + } + + if (id === 'uncategorized') { + // Sync no accounts + dispatch(setAccountsSyncing({ ids: [] })); + return false; + } + + let accountIdsToSync: string[]; + if (id === 'offbudget' || id === 'onbudget') { + const targetOffbudget = id === 'offbudget' ? 1 : 0; + accountIdsToSync = accounts + .filter( + ({ bank, closed, tombstone, offbudget }) => + !!bank && !closed && !tombstone && offbudget === targetOffbudget, + ) + .sort((a, b) => a.sort_order - b.sort_order) + .map(({ id }) => id); + } else if (id) { + accountIdsToSync = [id]; + } else { + // Default: all accounts + accountIdsToSync = accounts + .filter( + ({ bank, closed, tombstone }) => !!bank && !closed && !tombstone, + ) + .sort((a, b) => + a.offbudget === b.offbudget + ? a.sort_order - b.sort_order + : a.offbudget - b.offbudget, + ) + .map(({ id }) => id); + } + + dispatch(setAccountsSyncing({ ids: accountIdsToSync })); + + const simpleFinAccounts = accounts.filter( + a => + a.account_sync_source === 'simpleFin' && + accountIdsToSync.includes(a.id), + ); + + let isSyncSuccess = false; + const newTransactions: Array = []; + const matchedTransactions: Array = []; + const updatedAccounts: Array = []; + + if (simpleFinAccounts.length > 0) { + console.log('Using SimpleFin batch sync'); + + const res = await send('simplefin-batch-sync', { + ids: simpleFinAccounts.map(a => a.id), + }); + + for (const account of res) { + const success = handleSyncResponse( + account.accountId, + account.res, + dispatch, + queryClient, + newTransactions, + matchedTransactions, + updatedAccounts, + ); + if (success) isSyncSuccess = true; + } + + accountIdsToSync = accountIdsToSync.filter( + id => !simpleFinAccounts.find(sfa => sfa.id === id), + ); + } + + // Loop through the accounts and perform sync operation.. one by one + for (let idx = 0; idx < accountIdsToSync.length; idx++) { + const accountId = accountIdsToSync[idx]; + + // Perform sync operation + const res = await send('accounts-bank-sync', { + ids: [accountId], + }); + + const success = handleSyncResponse( + accountId, + res, + dispatch, + queryClient, + newTransactions, + matchedTransactions, + updatedAccounts, + ); + + if (success) isSyncSuccess = true; + + // Dispatch the ids for the accounts that are yet to be synced + dispatch(setAccountsSyncing({ ids: accountIdsToSync.slice(idx + 1) })); + } + + // Set new transactions + dispatch( + setNewTransactions({ + newTransactions, + matchedTransactions, + }), + ); + + dispatch(markUpdatedAccounts({ ids: updatedAccounts })); + + // Reset the sync state back to empty (fallback in case something breaks + // in the logic above) + dispatch(setAccountsSyncing({ ids: [] })); + return isSyncSuccess; + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error syncing accounts:', error); + dispatchErrorNotification( + dispatch, + t('There was an error syncing accounts. Please try again.'), + error, + ); + }, + }); +} + +function handleSyncResponse( + accountId: AccountEntity['id'], + res: SyncResponseWithErrors, + dispatch: AppDispatch, + queryClient: QueryClient, + resNewTransactions: Array, + resMatchedTransactions: Array, + resUpdatedAccounts: Array, +) { + const { errors, newTransactions, matchedTransactions, updatedAccounts } = res; + + // Mark the account as failed or succeeded (depending on sync output) + const [error] = errors; + if (error) { + // We only want to mark the account as having problem if it + // was a real syncing error. + if ('type' in error && error.type === 'SyncError') { + dispatch( + markAccountFailed({ + id: accountId, + errorType: error.category, + errorCode: error.code, + }), + ); + } + } else { + dispatch(markAccountSuccess({ id: accountId })); + } + + // Dispatch errors (if any) + errors.forEach(error => { + if ('type' in error && error.type === 'SyncError') { + dispatch( + addNotification({ + notification: { + type: 'error', + message: error.message, + }, + }), + ); + } else { + dispatch( + addNotification({ + notification: { + type: 'error', + message: error.message, + internal: 'internal' in error ? error.internal : undefined, + }, + }), + ); + } + }); + + resNewTransactions.push(...newTransactions); + resMatchedTransactions.push(...matchedTransactions); + resUpdatedAccounts.push(...updatedAccounts); + + invalidateQueries(queryClient); + + return newTransactions.length > 0 || matchedTransactions.length > 0; +} + +type SyncAndDownloadPayload = { + id?: AccountEntity['id']; +}; + +export function useSyncAndDownloadMutation() { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const syncAccounts = useSyncAccountsMutation(); + + return useMutation({ + mutationFn: async ({ id }: SyncAndDownloadPayload) => { + // It is *critical* that we sync first because of transaction + // reconciliation. We want to get all transactions that other + // clients have already made, so that imported transactions can be + // reconciled against them. Otherwise, two clients will each add + // new transactions from the bank and create duplicate ones. + const syncState = await dispatch(sync()).unwrap(); + if (syncState.error) { + return { error: syncState.error }; + } + + const hasDownloaded = await syncAccounts.mutateAsync({ id }); + + if (hasDownloaded) { + // Sync again afterwards if new transactions were created + const syncState = await dispatch(sync()).unwrap(); + if (syncState.error) { + return { error: syncState.error }; + } + + // `hasDownloaded` is already true, we know there has been + // updates + return true; + } + return { hasUpdated: hasDownloaded }; + }, + onSuccess: () => invalidateQueries(queryClient), + onError: error => { + console.error('Error syncing accounts:', error); + dispatchErrorNotification( + dispatch, + t('There was an error syncing accounts. Please try again.'), + error, + ); + }, + }); +} diff --git a/packages/desktop-client/src/accounts/queries.ts b/packages/desktop-client/src/accounts/queries.ts new file mode 100644 index 0000000000..250b07310f --- /dev/null +++ b/packages/desktop-client/src/accounts/queries.ts @@ -0,0 +1,46 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { send } from 'loot-core/platform/client/connection'; +import type { AccountEntity } from 'loot-core/types/models'; + +function selectActive(accounts: AccountEntity[]) { + return accounts.filter(account => !account.closed); +} + +export const accountQueries = { + all: () => ['accounts'], + lists: () => [...accountQueries.all(), 'lists'], + list: () => + queryOptions({ + queryKey: [...accountQueries.lists()], + queryFn: async () => { + const accounts: AccountEntity[] = await send('accounts-get'); + return accounts; + }, + placeholderData: [], + // Manually invalidated when accounts change + staleTime: Infinity, + }), + listActive: () => + queryOptions({ + ...accountQueries.list(), + select: selectActive, + }), + listClosed: () => + queryOptions({ + ...accountQueries.list(), + select: accounts => accounts.filter(account => !!account.closed), + }), + listOnBudget: () => + queryOptions({ + ...accountQueries.list(), + select: accounts => + selectActive(accounts).filter(account => !account.offbudget), + }), + listOffBudget: () => + queryOptions({ + ...accountQueries.list(), + select: accounts => + selectActive(accounts).filter(account => !!account.offbudget), + }), +}; diff --git a/packages/desktop-client/src/app/appSlice.ts b/packages/desktop-client/src/app/appSlice.ts index 96c9f96f9b..18732a4dfb 100644 --- a/packages/desktop-client/src/app/appSlice.ts +++ b/packages/desktop-client/src/app/appSlice.ts @@ -3,10 +3,8 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { send } from 'loot-core/platform/client/connection'; import { getUploadError } from 'loot-core/shared/errors'; -import type { AccountEntity } from 'loot-core/types/models'; import type { AtLeastOne } from 'loot-core/types/util'; -import { syncAccounts } from '@desktop-client/accounts/accountsSlice'; import { pushModal } from '@desktop-client/modals/modalsSlice'; import { loadPrefs } from '@desktop-client/prefs/prefsSlice'; import { createAppAsyncThunk } from '@desktop-client/redux'; @@ -127,42 +125,6 @@ export const getLatestAppVersion = createAppAsyncThunk( }, ); -type SyncAndDownloadPayload = { - accountId?: AccountEntity['id'] | string; -}; - -export const syncAndDownload = createAppAsyncThunk( - `${sliceName}/syncAndDownload`, - async ({ accountId }: SyncAndDownloadPayload, { dispatch }) => { - // It is *critical* that we sync first because of transaction - // reconciliation. We want to get all transactions that other - // clients have already made, so that imported transactions can be - // reconciled against them. Otherwise, two clients will each add - // new transactions from the bank and create duplicate ones. - const syncState = await dispatch(sync()).unwrap(); - if (syncState.error) { - return { error: syncState.error }; - } - - const hasDownloaded = await dispatch( - syncAccounts({ id: accountId }), - ).unwrap(); - - if (hasDownloaded) { - // Sync again afterwards if new transactions were created - const syncState = await dispatch(sync()).unwrap(); - if (syncState.error) { - return { error: syncState.error }; - } - - // `hasDownloaded` is already true, we know there has been - // updates - return true; - } - return { hasUpdated: hasDownloaded }; - }, -); - // Workaround for partial types in actions. // https://github.com/reduxjs/redux-toolkit/issues/1423#issuecomment-902680573 type SetAppStatePayload = AtLeastOne; @@ -195,7 +157,6 @@ export const actions = { updateApp, resetSync, sync, - syncAndDownload, getLatestAppVersion, }; diff --git a/packages/desktop-client/src/budget/mutations.ts b/packages/desktop-client/src/budget/mutations.ts index 948c6016f6..01138d460b 100644 --- a/packages/desktop-client/src/budget/mutations.ts +++ b/packages/desktop-client/src/budget/mutations.ts @@ -7,14 +7,13 @@ import { v4 as uuidv4 } from 'uuid'; import { sendCatch } from 'loot-core/platform/client/connection'; import type { send } from 'loot-core/platform/client/connection'; -import { logger } from 'loot-core/platform/server/log'; import type { IntegerAmount } from 'loot-core/shared/util'; import type { CategoryEntity, CategoryGroupEntity, } from 'loot-core/types/models'; -import { categoryQueries } from '.'; +import { categoryQueries } from './queries'; import { pushModal } from '@desktop-client/modals/modalsSlice'; import { addNotification } from '@desktop-client/notifications/notificationsSlice'; @@ -99,7 +98,7 @@ export function useCreateCategoryMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error creating category:', error); + console.error('Error creating category:', error); dispatchErrorNotification( dispatch, t('There was an error creating the category. Please try again.'), @@ -125,7 +124,7 @@ export function useUpdateCategoryMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error updating category:', error); + console.error('Error updating category:', error); dispatchErrorNotification( dispatch, t('There was an error updating the category. Please try again.'), @@ -149,17 +148,15 @@ export function useSaveCategoryMutation() { return useMutation({ mutationFn: async ({ category }: SaveCategoryPayload) => { - const { grouped: categoryGroups } = await queryClient.ensureQueryData( - categoryQueries.list(), - ); + 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(), + const exists = categoriesInGroup.some( + c => + c.id !== category.id && + c.name.toUpperCase() === category.name.toUpperCase(), ); if (exists) { @@ -230,7 +227,7 @@ export function useDeleteCategoryMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error deleting category:', error); + console.error('Error deleting category:', error); if (error) { switch (error.cause) { @@ -274,7 +271,7 @@ export function useMoveCategoryMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error moving category:', error); + console.error('Error moving category:', error); dispatchErrorNotification( dispatch, t('There was an error moving the category. Please try again.'), @@ -299,7 +296,7 @@ export function useReorderCategoryMutation() { return useMutation({ mutationFn: async ({ id, groupId, targetId }: ReoderCategoryPayload) => { - const { grouped: categoryGroups, list: categories } = + const { grouped: categoryGroups = [], list: categories = [] } = await queryClient.ensureQueryData(categoryQueries.list()); const moveCandidate = categories.filter(c => c.id === id)[0]; @@ -341,7 +338,7 @@ export function useCreateCategoryGroupMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error creating category group:', error); + console.error('Error creating category group:', error); dispatchErrorNotification( dispatch, t('There was an error creating the category group. Please try again.'), @@ -389,7 +386,7 @@ export function useUpdateCategoryGroupMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error updating category group:', error); + console.error('Error updating category group:', error); dispatchErrorNotification( dispatch, t('There was an error updating the category group. Please try again.'), @@ -472,7 +469,7 @@ export function useDeleteCategoryGroupMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error deleting category group:', error); + console.error('Error deleting category group:', error); dispatchErrorNotification( dispatch, t('There was an error deleting the category group. Please try again.'), @@ -499,7 +496,7 @@ export function useMoveCategoryGroupMutation() { }, onSuccess: () => invalidateQueries(queryClient), onError: error => { - logger.error('Error moving category group:', error); + console.error('Error moving category group:', error); dispatchErrorNotification( dispatch, t('There was an error moving the category group. Please try again.'), @@ -828,7 +825,7 @@ export function useBudgetActions() { } }, onError: error => { - logger.error('Error applying budget action:', error); + console.error('Error applying budget action:', error); dispatchErrorNotification( dispatch, t('There was an error applying the budget action. Please try again.'), diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index eb8e3f2255..1462a2bf1c 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -7,6 +7,7 @@ import { Navigate, Route, Routes, useHref, useLocation } from 'react-router'; import { useResponsive } from '@actual-app/components/hooks/useResponsive'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; +import { useQuery } from '@tanstack/react-query'; import * as undo from 'loot-core/platform/client/undo'; @@ -28,10 +29,10 @@ import { FloatableSidebar } from './sidebar'; import { ManageTagsPage } from './tags/ManageTagsPage'; import { Titlebar } from './Titlebar'; +import { accountQueries } from '@desktop-client/accounts'; import { getLatestAppVersion, sync } from '@desktop-client/app/appSlice'; import { ProtectedRoute } from '@desktop-client/auth/ProtectedRoute'; import { Permissions } from '@desktop-client/auth/types'; -import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor'; @@ -85,8 +86,10 @@ export function FinancesApp() { const dispatch = useDispatch(); const { t } = useTranslation(); - const accounts = useAccounts(); - const isAccountsLoaded = useSelector(state => state.account.isAccountsLoaded); + // TODO: Replace with `useAccounts` hook once it's updated to return the useQuery results. + const { data: accounts, isSuccess: isAccountsLoaded } = useQuery( + accountQueries.list(), + ); const versionInfo = useSelector(state => state.app.versionInfo); const [notifyWhenUpdateIsAvailable] = useGlobalPref( diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index 44dd9d95b1..397639efcd 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -41,12 +41,12 @@ import { AccountEmptyMessage } from './AccountEmptyMessage'; import { AccountHeader } from './Header'; import { - markAccountRead, - reopenAccount, - unlinkAccount, - updateAccount, -} from '@desktop-client/accounts/accountsSlice'; -import { syncAndDownload } from '@desktop-client/app/appSlice'; + useReopenAccountMutation, + useSyncAndDownloadMutation, + useUnlinkAccountMutation, + useUpdateAccountMutation, +} from '@desktop-client/accounts'; +import { markAccountRead } from '@desktop-client/accounts/accountsSlice'; import type { SavedFilter } from '@desktop-client/components/filters/SavedFilterMenuButton'; import { TransactionList } from '@desktop-client/components/transactions/TransactionList'; import { validateAccountName } from '@desktop-client/components/util/accountValidation'; @@ -245,7 +245,12 @@ type AccountInternalProps = { accountsSyncing: string[]; dispatch: AppDispatch; onSetTransfer: ReturnType['onSetTransfer']; + onReopenAccount: (id: AccountEntity['id']) => void; + onUpdateAccount: (account: AccountEntity) => void; + onUnlinkAccount: (id: AccountEntity['id']) => void; + onSyncAndDownload: (accountId?: AccountEntity['id']) => void; }; + type AccountInternalState = { search: string; filterConditions: ConditionEntity[]; @@ -568,9 +573,7 @@ class AccountInternal extends PureComponent< const accountId = this.props.accountId; const account = this.props.accounts.find(acct => acct.id === accountId); - await this.props.dispatch( - syncAndDownload({ accountId: account ? account.id : accountId }), - ); + this.props.onSyncAndDownload(account ? account.id : accountId); }; onImport = async () => { @@ -756,7 +759,7 @@ class AccountInternal extends PureComponent< if (!account) { throw new Error(`Account with ID ${this.props.accountId} not found.`); } - this.props.dispatch(updateAccount({ account: { ...account, name } })); + this.props.onUpdateAccount({ ...account, name }); this.setState({ nameError: '' }); } }; @@ -805,7 +808,7 @@ class AccountInternal extends PureComponent< accountName: account.name, isViewBankSyncSettings: false, onUnlink: () => { - this.props.dispatch(unlinkAccount({ id: accountId })); + this.props.onUnlinkAccount(accountId); }, }, }, @@ -816,7 +819,7 @@ class AccountInternal extends PureComponent< this.props.dispatch(openAccountCloseModal({ accountId })); break; case 'reopen': - this.props.dispatch(reopenAccount({ id: accountId })); + this.props.onReopenAccount(accountId); break; case 'export': const accountName = this.getAccountTitle(account, accountId); @@ -1023,11 +1026,7 @@ class AccountInternal extends PureComponent< } const lastReconciled = new Date().getTime().toString(); - this.props.dispatch( - updateAccount({ - account: { ...account, last_reconciled: lastReconciled }, - }), - ); + this.props.onUpdateAccount({ ...account, last_reconciled: lastReconciled }); this.setState({ reconcileAmount: null, @@ -1998,6 +1997,20 @@ export function Account() { [params.id], ); + const { mutate: reopenAccount } = useReopenAccountMutation(); + const onReopenAccount = (id: AccountEntity['id']) => reopenAccount({ id }); + + const { mutate: updateAccount } = useUpdateAccountMutation(); + const onUpdateAccount = (account: AccountEntity) => + updateAccount({ account }); + + const { mutate: unlinkAccount } = useUnlinkAccountMutation(); + const onUnlinkAccount = (id: AccountEntity['id']) => unlinkAccount({ id }); + + const { mutate: syncAndDownload } = useSyncAndDownloadMutation(); + const onSyncAndDownload = (id?: AccountEntity['id']) => + syncAndDownload({ id }); + return ( diff --git a/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx b/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx index 7485ed5801..0c49be9a24 100644 --- a/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx +++ b/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx @@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view'; import type { AccountEntity } from 'loot-core/types/models'; -import { unlinkAccount } from '@desktop-client/accounts/accountsSlice'; +import { useUnlinkAccountMutation } from '@desktop-client/accounts'; import { Link } from '@desktop-client/components/common/Link'; import { authorizeBank } from '@desktop-client/gocardless'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; @@ -105,15 +105,16 @@ export function AccountSyncCheck() { [dispatch], ); + const unlinkAccount = useUnlinkAccountMutation(); const unlink = useCallback( (acc: AccountEntity) => { if (acc.id) { - dispatch(unlinkAccount({ id: acc.id })); + unlinkAccount.mutate({ id: acc.id }); } setOpen(false); }, - [dispatch], + [unlinkAccount], ); if (!failedAccounts || !id) { diff --git a/packages/desktop-client/src/components/banksync/EditSyncAccount.tsx b/packages/desktop-client/src/components/banksync/EditSyncAccount.tsx index c221122449..43ccc93d9f 100644 --- a/packages/desktop-client/src/components/banksync/EditSyncAccount.tsx +++ b/packages/desktop-client/src/components/banksync/EditSyncAccount.tsx @@ -13,7 +13,7 @@ import { BankSyncCheckboxOptions } from './BankSyncCheckboxOptions'; import { FieldMapping } from './FieldMapping'; import { useBankSyncAccountSettings } from './useBankSyncAccountSettings'; -import { unlinkAccount } from '@desktop-client/accounts/accountsSlice'; +import { useUnlinkAccountMutation } from '@desktop-client/accounts'; import { Modal, ModalCloseButton, @@ -161,6 +161,7 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) { close(); }; + const unlinkAccount = useUnlinkAccountMutation(); const onUnlink = async (close: () => void) => { dispatch( pushModal({ @@ -170,8 +171,12 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) { accountName: account.name, isViewBankSyncSettings: true, onUnlink: () => { - dispatch(unlinkAccount({ id: account.id })); - close(); + unlinkAccount.mutate( + { id: account.id }, + { + onSuccess: close, + }, + ); }, }, }, diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountPage.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountPage.tsx index 071a75c025..948564ccfe 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountPage.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountPage.tsx @@ -18,9 +18,9 @@ import { OffBudgetAccountTransactions } from './OffBudgetAccountTransactions'; import { OnBudgetAccountTransactions } from './OnBudgetAccountTransactions'; import { - reopenAccount, - updateAccount, -} from '@desktop-client/accounts/accountsSlice'; + useReopenAccountMutation, + useUpdateAccountMutation, +} from '@desktop-client/accounts'; import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton'; import { AddTransactionButton } from '@desktop-client/components/mobile/transactions/AddTransactionButton'; import { MobilePageHeader, Page } from '@desktop-client/components/Page'; @@ -109,12 +109,13 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) { ); const dispatch = useDispatch(); + const { mutate: updateAccount } = useUpdateAccountMutation(); const onSave = useCallback( (account: AccountEntity) => { - dispatch(updateAccount({ account })); + updateAccount({ account }); }, - [dispatch], + [updateAccount], ); const onSaveNotes = useCallback(async (id: string, notes: string) => { @@ -143,9 +144,11 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) { dispatch(openAccountCloseModal({ accountId: account.id })); }, [account.id, dispatch]); + const { mutate: reopenAccount } = useReopenAccountMutation(); + const onReopenAccount = useCallback(() => { - dispatch(reopenAccount({ id: account.id })); - }, [account.id, dispatch]); + reopenAccount({ id: account.id }); + }, [account.id, reopenAccount]); const [showRunningBalances, setShowRunningBalances] = useSyncedPref( `show-balances-${account.id}`, diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index ef1c94f753..8597ee9a8e 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -7,8 +7,8 @@ import { isPreviewId } from 'loot-core/shared/transactions'; import type { IntegerAmount } from 'loot-core/shared/util'; import type { AccountEntity, TransactionEntity } from 'loot-core/types/models'; +import { useSyncAndDownloadMutation } from '@desktop-client/accounts'; import { markAccountRead } from '@desktop-client/accounts/accountsSlice'; -import { syncAndDownload } from '@desktop-client/app/appSlice'; import { TransactionListWithBalances } from '@desktop-client/components/mobile/transactions/TransactionListWithBalances'; import { useAccountPreviewTransactions } from '@desktop-client/hooks/useAccountPreviewTransactions'; import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules'; @@ -108,11 +108,12 @@ function TransactionListWithPreviews({ accountId: account?.id, }); + const syncAndDownload = useSyncAndDownloadMutation(); const onRefresh = useCallback(() => { if (account.id) { - dispatch(syncAndDownload({ accountId: account.id })); + syncAndDownload.mutate({ id: account.id }); } - }, [account.id, dispatch]); + }, [account.id, syncAndDownload]); const allBalances = useMemo( () => diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountsPage.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountsPage.tsx index 503f835239..6ba3f121a8 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountsPage.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountsPage.tsx @@ -24,8 +24,10 @@ import { css } from '@emotion/css'; import type { AccountEntity } from 'loot-core/types/models'; -import { moveAccount } from '@desktop-client/accounts/accountsSlice'; -import { syncAndDownload } from '@desktop-client/app/appSlice'; +import { + useMoveAccountMutation, + useSyncAndDownloadMutation, +} from '@desktop-client/accounts'; import { makeAmountFullStyle } from '@desktop-client/components/budget/util'; import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs'; import { PullToRefresh } from '@desktop-client/components/mobile/PullToRefresh'; @@ -182,26 +184,23 @@ function AccountListItem({ flexDirection: 'row', }} > - { - /* TODO: Should bankId be part of the AccountEntity type? */ - 'bankId' in account && account.bankId ? ( - - ) : null - } + {account.bankId ? ( + + ) : null} ( state => state.account.accountsSyncing, ); const updatedAccounts = useSelector(state => state.account.updatedAccounts); - const dispatch = useDispatch(); + + const moveAccount = useMoveAccountMutation(); const { dragAndDropHooks } = useDragAndDrop({ getItems: keys => @@ -441,12 +441,10 @@ const AccountList = forwardRef( const targetAccountId = e.target.key as AccountEntity['id']; if (e.target.dropPosition === 'before') { - dispatch( - moveAccount({ - id: accountIdToMove, - targetId: targetAccountId, - }), - ); + moveAccount.mutate({ + id: accountIdToMove, + targetId: targetAccountId, + }); } else if (e.target.dropPosition === 'after') { const targetAccountIndex = accounts.findIndex( account => account.id === e.target.key, @@ -459,17 +457,15 @@ const AccountList = forwardRef( const nextToTargetAccount = accounts[targetAccountIndex + 1]; - dispatch( - moveAccount({ - id: accountIdToMove, - // Due to the way `moveAccount` works, we use the account next to the - // actual target account here because `moveAccount` always shoves the - // account *before* the target account. - // On the other hand, using `null` as `targetId`moves the account - // to the end of the list. - targetId: nextToTargetAccount?.id || null, - }), - ); + moveAccount.mutate({ + id: accountIdToMove, + // Due to the way `moveAccount` works, we use the account next to the + // actual target account here because `moveAccount` always shoves the + // account *before* the target account. + // On the other hand, using `null` as `targetId`moves the account + // to the end of the list. + targetId: nextToTargetAccount?.id || null, + }); } }, }); @@ -528,9 +524,10 @@ export function AccountsPage() { dispatch(replaceModal({ modal: { name: 'add-account', options: {} } })); }, [dispatch]); + const syncAndDownload = useSyncAndDownloadMutation(); const onSync = useCallback(async () => { - dispatch(syncAndDownload({})); - }, [dispatch]); + syncAndDownload.mutate({}); + }, [syncAndDownload]); return ( diff --git a/packages/desktop-client/src/components/mobile/banksync/MobileBankSyncAccountEditPage.tsx b/packages/desktop-client/src/components/mobile/banksync/MobileBankSyncAccountEditPage.tsx index b61fba5a64..b65147a03f 100644 --- a/packages/desktop-client/src/components/mobile/banksync/MobileBankSyncAccountEditPage.tsx +++ b/packages/desktop-client/src/components/mobile/banksync/MobileBankSyncAccountEditPage.tsx @@ -8,7 +8,7 @@ import { Text } from '@actual-app/components/text'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; -import { unlinkAccount } from '@desktop-client/accounts/accountsSlice'; +import { useUnlinkAccountMutation } from '@desktop-client/accounts'; import { BankSyncCheckboxOptions } from '@desktop-client/components/banksync/BankSyncCheckboxOptions'; import { FieldMapping } from '@desktop-client/components/banksync/FieldMapping'; import { useBankSyncAccountSettings } from '@desktop-client/components/banksync/useBankSyncAccountSettings'; @@ -54,6 +54,7 @@ export function MobileBankSyncAccountEditPage() { navigate('/bank-sync'); }; + const unlinkAccount = useUnlinkAccountMutation(); const handleUnlink = () => { dispatch( pushModal({ @@ -64,8 +65,12 @@ export function MobileBankSyncAccountEditPage() { isViewBankSyncSettings: true, onUnlink: () => { if (accountId) { - dispatch(unlinkAccount({ id: accountId })); - navigate('/bank-sync'); + unlinkAccount.mutate( + { id: accountId }, + { + onSuccess: () => navigate('/bank-sync'), + }, + ); } }, }, diff --git a/packages/desktop-client/src/components/modals/CloseAccountModal.tsx b/packages/desktop-client/src/components/modals/CloseAccountModal.tsx index 44f3dd28ff..2c623e33ea 100644 --- a/packages/desktop-client/src/components/modals/CloseAccountModal.tsx +++ b/packages/desktop-client/src/components/modals/CloseAccountModal.tsx @@ -17,7 +17,7 @@ import { integerToCurrency } from 'loot-core/shared/util'; import type { AccountEntity } from 'loot-core/types/models'; import type { TransObjectLiteral } from 'loot-core/types/util'; -import { closeAccount } from '@desktop-client/accounts/accountsSlice'; +import { useCloseAccountMutation } from '@desktop-client/accounts'; import { AccountAutocomplete } from '@desktop-client/components/autocomplete/AccountAutocomplete'; import { CategoryAutocomplete } from '@desktop-client/components/autocomplete/CategoryAutocomplete'; import { Link } from '@desktop-client/components/common/Link'; @@ -96,6 +96,8 @@ export function CloseAccountModal({ } : {}; + const closeAccount = useCloseAccountMutation(); + const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -112,13 +114,12 @@ export function CloseAccountModal({ setLoading(true); - dispatch( - closeAccount({ - id: account.id, - transferAccountId: transferAccountId || null, - categoryId: categoryId || null, - }), - ); + closeAccount.mutate({ + id: account.id, + transferAccountId: transferAccountId || null, + categoryId: categoryId || null, + }); + return true; }; @@ -280,12 +281,10 @@ export function CloseAccountModal({ variant="text" onClick={() => { setLoading(true); - dispatch( - closeAccount({ - id: account.id, - forced: true, - }), - ); + closeAccount.mutate({ + id: account.id, + forced: true, + }); close(); }} style={{ color: theme.errorText }} diff --git a/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx b/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx index f9e7c62d7a..704e807f0d 100644 --- a/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx +++ b/packages/desktop-client/src/components/modals/CreateLocalAccountModal.tsx @@ -15,7 +15,7 @@ import { View } from '@actual-app/components/view'; import { toRelaxedNumber } from 'loot-core/shared/util'; -import { createAccount } from '@desktop-client/accounts/accountsSlice'; +import { useCreateAccountMutation } from '@desktop-client/accounts'; import { Link } from '@desktop-client/components/common/Link'; import { Modal, @@ -55,6 +55,8 @@ export function CreateLocalAccountModal() { } }; + const createAccount = useCreateAccountMutation(); + const onSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -64,15 +66,19 @@ export function CreateLocalAccountModal() { setBalanceError(balanceError); if (!nameError && !balanceError) { - dispatch(closeModal()); - const id = await dispatch( - createAccount({ + createAccount.mutate( + { name, balance: toRelaxedNumber(balance), offBudget: offbudget, - }), - ).unwrap(); - navigate('/accounts/' + id); + }, + { + onSuccess: id => { + dispatch(closeModal()); + navigate('/accounts/' + id); + }, + }, + ); } }; return ( diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx index cf1af04fa3..7cade9562d 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx +++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx @@ -32,9 +32,9 @@ import { import type { DateFormat, FieldMapping, ImportTransaction } from './utils'; import { - importPreviewTransactions, - importTransactions, -} from '@desktop-client/accounts/accountsSlice'; + useImportPreviewTransactionsMutation, + useImportTransactionsMutation, +} from '@desktop-client/accounts'; import { Modal, ModalCloseButton, @@ -307,59 +307,9 @@ export function ImportTransactionsModal({ }); } - // Retreive the transactions that would be updated (along with the existing trx) - const previewTrx = await dispatch( - importPreviewTransactions({ - accountId, - transactions: previewTransactions, - }), - ).unwrap(); - const matchedUpdateMap = previewTrx.reduce((map, entry) => { - // @ts-expect-error - entry.transaction might not have trx_id property - map[entry.transaction.trx_id] = entry; - return map; - }, {}); - - return transactions - .filter(trans => !trans.isMatchedTransaction) - .reduce((previous, current_trx) => { - let next = previous; - const entry = matchedUpdateMap[current_trx.trx_id]; - const existing_trx = entry?.existing; - - // if the transaction is matched with an existing one for update - current_trx.existing = !!existing_trx; - // if the transaction is an update that will be ignored - // (reconciled transactions or no change detected) - current_trx.ignored = entry?.ignored || false; - - current_trx.tombstone = entry?.tombstone || false; - - current_trx.selected = !current_trx.ignored; - current_trx.selected_merge = current_trx.existing; - - next = next.concat({ ...current_trx }); - - if (existing_trx) { - // 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.find( - cat => cat.id === existing_trx.category, - )?.name; - // add parent transaction attribute to mimic behaviour - existing_trx.trx_id = current_trx.trx_id; - existing_trx.existing = current_trx.existing; - existing_trx.selected = current_trx.selected; - existing_trx.selected_merge = current_trx.selected_merge; - - next = next.concat({ ...existing_trx }); - } - - return next; - }, []); + return previewTransactions; }, - [accountId, categories, clearOnImport, dispatch], + [categories, clearOnImport], ); const parse = useCallback( @@ -574,6 +524,8 @@ export function ImportTransactionsModal({ setTransactions(newTransactions); } + const importTransactions = useImportTransactionsMutation(); + async function onImport(close) { setLoadingState('importing'); @@ -695,26 +647,33 @@ export function ImportTransactionsModal({ }); } - const didChange = await dispatch( - importTransactions({ + importTransactions.mutate( + { accountId, transactions: finalTransactions, reconcile, - }), - ).unwrap(); - if (didChange) { - await dispatch(reloadPayees()); - } + }, + { + onSuccess: async didChange => { + if (didChange) { + await dispatch(reloadPayees()); + } - if (onImported) { - onImported(didChange); - } - close(); + if (onImported) { + onImported(didChange); + } + + close(); + }, + }, + ); } + const importPreviewTransactions = useImportPreviewTransactionsMutation(); + const onImportPreview = useEffectEvent(async () => { // always start from the original parsed transactions, not the previewed ones to ensure rules run - const transactionPreview = await getImportPreview( + const previewTransactionsToImport = await getImportPreview( parsedTransactions, filetype, flipAmount, @@ -725,7 +684,64 @@ export function ImportTransactionsModal({ outValue, multiplierAmount, ); - setTransactions(transactionPreview); + + // Retreive the transactions that would be updated (along with the existing trx) + importPreviewTransactions.mutate( + { + accountId, + transactions: previewTransactionsToImport, + }, + { + onSuccess: previewTrx => { + const matchedUpdateMap = previewTrx.reduce((map, entry) => { + // @ts-expect-error - entry.transaction might not have trx_id property + map[entry.transaction.trx_id] = entry; + return map; + }, {}); + + const previewTransactions = parsedTransactions + .filter(trans => !trans.isMatchedTransaction) + .reduce((previous, currentTrx) => { + let next = previous; + const entry = matchedUpdateMap[currentTrx.trx_id]; + const existingTrx = entry?.existing; + + // if the transaction is matched with an existing one for update + currentTrx.existing = !!existingTrx; + // if the transaction is an update that will be ignored + // (reconciled transactions or no change detected) + currentTrx.ignored = entry?.ignored || false; + + currentTrx.tombstone = entry?.tombstone || false; + + currentTrx.selected = !currentTrx.ignored; + currentTrx.selected_merge = currentTrx.existing; + + next = next.concat({ ...currentTrx }); + + if (existingTrx) { + // add the updated existing transaction in the list, with the + // isMatchedTransaction flag to identify it in display and not send it again + existingTrx.isMatchedTransaction = true; + existingTrx.category = categories.find( + cat => cat.id === existingTrx.category, + )?.name; + // add parent transaction attribute to mimic behaviour + existingTrx.trx_id = currentTrx.trx_id; + existingTrx.existing = currentTrx.existing; + existingTrx.selected = currentTrx.selected; + existingTrx.selected_merge = currentTrx.selected_merge; + + next = next.concat({ ...existingTrx }); + } + + return next; + }, []); + + setTransactions(previewTransactions); + }, + }, + ); }); useEffect(() => { diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx index dafaba8eef..a4983ac900 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx @@ -21,11 +21,11 @@ import type { } from 'loot-core/types/models'; import { - linkAccount, - linkAccountPluggyAi, - linkAccountSimpleFin, - unlinkAccount, -} from '@desktop-client/accounts/accountsSlice'; + useLinkAccountMutation, + useLinkAccountPluggyAiMutation, + useLinkAccountSimpleFinMutation, + useUnlinkAccountMutation, +} from '@desktop-client/accounts'; import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete'; import type { AutocompleteItem } from '@desktop-client/components/autocomplete/Autocomplete'; import { @@ -154,6 +154,11 @@ export function SelectLinkedAccountsModal({ const { addOnBudgetAccountOption, addOffBudgetAccountOption } = useAddBudgetAccountOptions(); + const linkAccount = useLinkAccountMutation(); + const unlinkAccount = useUnlinkAccountMutation(); + const linkAccountSimpleFin = useLinkAccountSimpleFinMutation(); + const linkAccountPluggyAi = useLinkAccountPluggyAiMutation(); + async function onNext() { const chosenLocalAccountIds = Object.values(chosenAccounts); @@ -162,7 +167,7 @@ export function SelectLinkedAccountsModal({ localAccounts .filter(acc => acc.account_id) .filter(acc => !chosenLocalAccountIds.includes(acc.id)) - .forEach(acc => dispatch(unlinkAccount({ id: acc.id }))); + .forEach(acc => unlinkAccount.mutate({ id: acc.id })); // Link new accounts Object.entries(chosenAccounts).forEach( @@ -189,57 +194,51 @@ export function SelectLinkedAccountsModal({ customSettings?.amount != null ? customSettings.amount : undefined; if (propsWithSortedExternalAccounts.syncSource === 'simpleFin') { - dispatch( - linkAccountSimpleFin({ - externalAccount: - propsWithSortedExternalAccounts.externalAccounts[ - externalAccountIndex - ], - upgradingId: - chosenLocalAccountId !== addOnBudgetAccountOption.id && - chosenLocalAccountId !== addOffBudgetAccountOption.id - ? chosenLocalAccountId - : undefined, - offBudget, - startingDate, - startingBalance, - }), - ); + linkAccountSimpleFin.mutate({ + externalAccount: + propsWithSortedExternalAccounts.externalAccounts[ + externalAccountIndex + ], + upgradingId: + chosenLocalAccountId !== addOnBudgetAccountOption.id && + chosenLocalAccountId !== addOffBudgetAccountOption.id + ? chosenLocalAccountId + : undefined, + offBudget, + startingDate, + startingBalance, + }); } else if (propsWithSortedExternalAccounts.syncSource === 'pluggyai') { - dispatch( - linkAccountPluggyAi({ - externalAccount: - propsWithSortedExternalAccounts.externalAccounts[ - externalAccountIndex - ], - upgradingId: - chosenLocalAccountId !== addOnBudgetAccountOption.id && - chosenLocalAccountId !== addOffBudgetAccountOption.id - ? chosenLocalAccountId - : undefined, - offBudget, - startingDate, - startingBalance, - }), - ); + linkAccountPluggyAi.mutate({ + externalAccount: + propsWithSortedExternalAccounts.externalAccounts[ + externalAccountIndex + ], + upgradingId: + chosenLocalAccountId !== addOnBudgetAccountOption.id && + chosenLocalAccountId !== addOffBudgetAccountOption.id + ? chosenLocalAccountId + : undefined, + offBudget, + startingDate, + startingBalance, + }); } else { - dispatch( - linkAccount({ - requisitionId: propsWithSortedExternalAccounts.requisitionId, - account: - propsWithSortedExternalAccounts.externalAccounts[ - externalAccountIndex - ], - upgradingId: - chosenLocalAccountId !== addOnBudgetAccountOption.id && - chosenLocalAccountId !== addOffBudgetAccountOption.id - ? chosenLocalAccountId - : undefined, - offBudget, - startingDate, - startingBalance, - }), - ); + linkAccount.mutate({ + requisitionId: propsWithSortedExternalAccounts.requisitionId, + account: + propsWithSortedExternalAccounts.externalAccounts[ + externalAccountIndex + ], + upgradingId: + chosenLocalAccountId !== addOnBudgetAccountOption.id && + chosenLocalAccountId !== addOffBudgetAccountOption.id + ? chosenLocalAccountId + : undefined, + offBudget, + startingDate, + startingBalance, + }); } }, ); diff --git a/packages/desktop-client/src/components/sidebar/Account.tsx b/packages/desktop-client/src/components/sidebar/Account.tsx index 27df2b4992..50223c6ce6 100644 --- a/packages/desktop-client/src/components/sidebar/Account.tsx +++ b/packages/desktop-client/src/components/sidebar/Account.tsx @@ -24,9 +24,9 @@ import { css, cx } from '@emotion/css'; import type { AccountEntity } from 'loot-core/types/models'; import { - reopenAccount, - updateAccount, -} from '@desktop-client/accounts/accountsSlice'; + useReopenAccountMutation, + useUpdateAccountMutation, +} from '@desktop-client/accounts'; import { BalanceHistoryGraph } from '@desktop-client/components/accounts/BalanceHistoryGraph'; import { Link } from '@desktop-client/components/common/Link'; import { Notes } from '@desktop-client/components/Notes'; @@ -136,6 +136,8 @@ export function Account>({ window.matchMedia('(hover: none)').matches || window.matchMedia('(pointer: coarse)').matches; const needsTooltip = !!account?.id && !isTouchDevice; + const reopenAccount = useReopenAccountMutation(); + const updateAccount = useUpdateAccountMutation(); const accountRow = ( >({ onBlur={() => setIsEditing(false)} onEnter={newAccountName => { if (newAccountName.trim() !== '') { - dispatch( - updateAccount({ - account: { - ...account, - name: newAccountName, - }, - }), - ); + updateAccount.mutate({ + account: { + ...account, + name: newAccountName, + }, + }); } setIsEditing(false); }} @@ -264,7 +264,7 @@ export function Account>({ break; } case 'reopen': { - dispatch(reopenAccount({ id: account.id })); + reopenAccount.mutate({ id: account.id }); break; } case 'rename': { diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index c870cad852..8912c9f263 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -9,7 +9,7 @@ import type { AccountEntity } from 'loot-core/types/models'; import { Account } from './Account'; import { SecondaryItem } from './SecondaryItem'; -import { moveAccount } from '@desktop-client/accounts/accountsSlice'; +import { useMoveAccountMutation } from '@desktop-client/accounts'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useClosedAccounts } from '@desktop-client/hooks/useClosedAccounts'; import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts'; @@ -17,14 +17,13 @@ import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useOffBudgetAccounts } from '@desktop-client/hooks/useOffBudgetAccounts'; import { useOnBudgetAccounts } from '@desktop-client/hooks/useOnBudgetAccounts'; import { useUpdatedAccounts } from '@desktop-client/hooks/useUpdatedAccounts'; -import { useDispatch, useSelector } from '@desktop-client/redux'; +import { useSelector } from '@desktop-client/redux'; import * as bindings from '@desktop-client/spreadsheet/bindings'; const fontWeight = 600; export function Accounts() { const { t } = useTranslation(); - const dispatch = useDispatch(); const [isDragging, setIsDragging] = useState(false); const accounts = useAccounts(); const failedAccounts = useFailedAccounts(); @@ -44,6 +43,8 @@ export function Accounts() { setIsDragging(drag.state === 'start'); } + const moveAccount = useMoveAccountMutation(); + const makeDropPadding = (i: number) => { if (i === 0) { return { @@ -65,7 +66,7 @@ export function Accounts() { targetIdToMove = idx < accounts.length ? accounts[idx].id : null; } - dispatch(moveAccount({ id, targetId: targetIdToMove as string })); + moveAccount.mutate({ id, targetId: targetIdToMove as string }); } const onToggleClosedAccounts = () => { diff --git a/packages/desktop-client/src/global-events.ts b/packages/desktop-client/src/global-events.ts index b1039233d4..fcd24461b3 100644 --- a/packages/desktop-client/src/global-events.ts +++ b/packages/desktop-client/src/global-events.ts @@ -4,7 +4,7 @@ import type { QueryClient } from '@tanstack/react-query'; import { listen } from 'loot-core/platform/client/connection'; import * as undo from 'loot-core/platform/client/undo'; -import { reloadAccounts } from './accounts/accountsSlice'; +import { accountQueries } from './accounts'; import { setAppState } from './app/appSlice'; import { categoryQueries } from './budget'; import { closeBudgetUI } from './budgetfiles/budgetfilesSlice'; @@ -74,7 +74,11 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) { } if (tables.includes('accounts')) { - promises.push(store.dispatch(reloadAccounts())); + promises.push( + queryClient.invalidateQueries({ + queryKey: accountQueries.lists(), + }), + ); } const tagged = undo.getTaggedState(undoTag); diff --git a/packages/desktop-client/src/hooks/useAccount.ts b/packages/desktop-client/src/hooks/useAccount.ts index e3da8f35e6..cb7bdb8f9a 100644 --- a/packages/desktop-client/src/hooks/useAccount.ts +++ b/packages/desktop-client/src/hooks/useAccount.ts @@ -1,8 +1,11 @@ -import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; -import { useAccounts } from './useAccounts'; +import { accountQueries } from '@desktop-client/accounts'; export function useAccount(id: string) { - const accounts = useAccounts(); - return useMemo(() => accounts.find(a => a.id === id), [id, accounts]); + const query = useQuery({ + ...accountQueries.list(), + select: data => data.find(c => c.id === id), + }); + return query.data; } diff --git a/packages/desktop-client/src/hooks/useAccounts.ts b/packages/desktop-client/src/hooks/useAccounts.ts index b2910a5baf..2bd521dace 100644 --- a/packages/desktop-client/src/hooks/useAccounts.ts +++ b/packages/desktop-client/src/hooks/useAccounts.ts @@ -1,20 +1,10 @@ -import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; -import { useInitialMount } from './useInitialMount'; - -import { getAccounts } from '@desktop-client/accounts/accountsSlice'; -import { useDispatch, useSelector } from '@desktop-client/redux'; +import { accountQueries } from '@desktop-client/accounts'; export function useAccounts() { - const dispatch = useDispatch(); - const isInitialMount = useInitialMount(); - const isAccountsDirty = useSelector(state => state.account.isAccountsDirty); - - useEffect(() => { - if (isInitialMount || isAccountsDirty) { - dispatch(getAccounts()); - } - }, [dispatch, isInitialMount, isAccountsDirty]); - - return useSelector(state => state.account.accounts); + const query = useQuery(accountQueries.list()); + // TODO: Update to return query states (e.g. isFetching, isError, etc) + // so clients can handle loading and error states appropriately. + return query.data ?? []; } diff --git a/packages/desktop-client/src/hooks/useClosedAccounts.ts b/packages/desktop-client/src/hooks/useClosedAccounts.ts index 85aa5b9204..5d2cd3f283 100644 --- a/packages/desktop-client/src/hooks/useClosedAccounts.ts +++ b/packages/desktop-client/src/hooks/useClosedAccounts.ts @@ -1,11 +1,10 @@ -import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; -import { useAccounts } from './useAccounts'; +import { accountQueries } from '@desktop-client/accounts'; export function useClosedAccounts() { - const accounts = useAccounts(); - return useMemo( - () => accounts.filter(account => account.closed === 1), - [accounts], - ); + const query = useQuery(accountQueries.listClosed()); + // TODO: Update to return query states (e.g. isFetching, isError, etc) + // so clients can handle loading and error states appropriately. + return query.data ?? []; } diff --git a/packages/desktop-client/src/hooks/useOffBudgetAccounts.ts b/packages/desktop-client/src/hooks/useOffBudgetAccounts.ts index 71a5db919b..da0c939396 100644 --- a/packages/desktop-client/src/hooks/useOffBudgetAccounts.ts +++ b/packages/desktop-client/src/hooks/useOffBudgetAccounts.ts @@ -1,14 +1,8 @@ -import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; -import { useAccounts } from './useAccounts'; +import { accountQueries } from '@desktop-client/accounts'; export function useOffBudgetAccounts() { - const accounts = useAccounts(); - return useMemo( - () => - accounts.filter( - account => account.closed === 0 && account.offbudget === 1, - ), - [accounts], - ); + const query = useQuery(accountQueries.listOffBudget()); + return query.data ?? []; } diff --git a/packages/desktop-client/src/hooks/useOnBudgetAccounts.ts b/packages/desktop-client/src/hooks/useOnBudgetAccounts.ts index 31fd6cf10c..edcae989cb 100644 --- a/packages/desktop-client/src/hooks/useOnBudgetAccounts.ts +++ b/packages/desktop-client/src/hooks/useOnBudgetAccounts.ts @@ -1,14 +1,8 @@ -import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; -import { useAccounts } from './useAccounts'; +import { accountQueries } from '@desktop-client/accounts'; export function useOnBudgetAccounts() { - const accounts = useAccounts(); - return useMemo( - () => - accounts.filter( - account => account.closed === 0 && account.offbudget === 0, - ), - [accounts], - ); + const query = useQuery(accountQueries.listOnBudget()); + return query.data ?? []; } diff --git a/packages/desktop-client/src/mocks.tsx b/packages/desktop-client/src/mocks.tsx index 3da4a6d7b2..9510e710ba 100644 --- a/packages/desktop-client/src/mocks.tsx +++ b/packages/desktop-client/src/mocks.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import type { ReactNode } from 'react'; import { Provider } from 'react-redux'; diff --git a/packages/desktop-client/src/modals/modalsSlice.ts b/packages/desktop-client/src/modals/modalsSlice.ts index ea9b1e724b..9c03637707 100644 --- a/packages/desktop-client/src/modals/modalsSlice.ts +++ b/packages/desktop-client/src/modals/modalsSlice.ts @@ -20,6 +20,7 @@ import type { } from 'loot-core/types/models'; import type { Template } from 'loot-core/types/models/templates'; +import { accountQueries } from '@desktop-client/accounts'; import { resetApp, setAppState } from '@desktop-client/app/appSlice'; import type { SelectLinkedAccountsModalProps } from '@desktop-client/components/modals/SelectLinkedAccountsModal'; import { createAppAsyncThunk } from '@desktop-client/redux'; @@ -592,10 +593,7 @@ type OpenAccountCloseModalPayload = { export const openAccountCloseModal = createAppAsyncThunk( `${sliceName}/openAccountCloseModal`, - async ( - { accountId }: OpenAccountCloseModalPayload, - { dispatch, getState }, - ) => { + async ({ accountId }: OpenAccountCloseModalPayload, { dispatch, extra }) => { const { balance, numTransactions, @@ -605,9 +603,9 @@ export const openAccountCloseModal = createAppAsyncThunk( id: accountId, }, ); - const account = getState().account.accounts.find( - acct => acct.id === accountId, - ); + const queryClient = extra.queryClient; + const accounts = await queryClient.ensureQueryData(accountQueries.list()); + const account = accounts.find(acct => acct.id === accountId); if (!account) { throw new Error(`Account with ID ${accountId} does not exist.`); diff --git a/packages/desktop-client/src/sync-events.ts b/packages/desktop-client/src/sync-events.ts index 70862a6528..20995aeb6a 100644 --- a/packages/desktop-client/src/sync-events.ts +++ b/packages/desktop-client/src/sync-events.ts @@ -4,7 +4,7 @@ import { t } from 'i18next'; import { listen, send } from 'loot-core/platform/client/connection'; -import { reloadAccounts } from './accounts/accountsSlice'; +import { accountQueries } from './accounts'; import { resetSync, sync } from './app/appSlice'; import { categoryQueries } from './budget'; import { @@ -86,7 +86,9 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) { } if (tables.includes('accounts')) { - store.dispatch(reloadAccounts()); + queryClient.invalidateQueries({ + queryKey: accountQueries.lists(), + }); } } else if (event.type === 'error') { let notif: Notification | null = null; diff --git a/packages/loot-core/src/mocks/index.ts b/packages/loot-core/src/mocks/index.ts index e80564df9f..051af3872f 100644 --- a/packages/loot-core/src/mocks/index.ts +++ b/packages/loot-core/src/mocks/index.ts @@ -31,7 +31,7 @@ export function generateAccount( return { ...offlineAccount, balance_current: Math.floor(random() * 100000), - bankId: Math.floor(random() * 10000), + bankId: Math.floor(random() * 10000).toString(), bankName: 'boa', bank: Math.floor(random() * 10000).toString(), account_id: 'idx', diff --git a/packages/loot-core/src/server/accounts/app.ts b/packages/loot-core/src/server/accounts/app.ts index abb41aa843..ac7aeb993c 100644 --- a/packages/loot-core/src/server/accounts/app.ts +++ b/packages/loot-core/src/server/accounts/app.ts @@ -89,8 +89,31 @@ async function updateAccount({ return {}; } -async function getAccounts() { - return db.getAccounts(); +async function getAccounts(): Promise { + const dbAccounts = await db.getAccounts(); + return dbAccounts.map( + dbAccount => + ({ + id: dbAccount.id, + name: dbAccount.name, + offbudget: dbAccount.offbudget, + closed: dbAccount.closed, + sort_order: dbAccount.sort_order, + last_reconciled: dbAccount.last_reconciled ?? null, + tombstone: dbAccount.tombstone, + account_id: dbAccount.account_id ?? null, + bank: dbAccount.bank ?? null, + bankName: dbAccount.bankName ?? null, + bankId: dbAccount.bankId ?? null, + mask: dbAccount.mask ?? null, + official_name: dbAccount.official_name ?? null, + balance_current: dbAccount.balance_current ?? null, + balance_available: dbAccount.balance_available ?? null, + balance_limit: dbAccount.balance_limit ?? null, + account_sync_source: dbAccount.account_sync_source ?? null, + last_sync: dbAccount.last_sync ?? null, + }) as AccountEntity, + ); } async function getAccountBalance({ diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 1a4ac9c995..20c14ecd33 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -571,8 +571,7 @@ handlers['api/transaction-delete'] = withMutation(async function ({ id }) { handlers['api/accounts-get'] = async function () { checkFileOpen(); - // TODO: Force cast to AccountEntity. This should be updated to an AQL query. - const accounts = (await db.getAccounts()) as AccountEntity[]; + const accounts: AccountEntity[] = await handlers['accounts-get'](); return accounts.map(account => accountModel.toExternal(account)); }; diff --git a/packages/loot-core/src/server/db/types/index.ts b/packages/loot-core/src/server/db/types/index.ts index de184305f4..5dc0cb0a41 100644 --- a/packages/loot-core/src/server/db/types/index.ts +++ b/packages/loot-core/src/server/db/types/index.ts @@ -22,6 +22,8 @@ export type DbAccount = { subtype?: string | null; bank?: string | null; account_sync_source?: 'simpleFin' | 'goCardless' | null; + last_reconciled?: string | null; + last_sync?: string | null; }; export type DbBank = { diff --git a/packages/loot-core/src/types/models/account.ts b/packages/loot-core/src/types/models/account.ts index b10817fc72..018f16ea95 100644 --- a/packages/loot-core/src/types/models/account.ts +++ b/packages/loot-core/src/types/models/account.ts @@ -12,7 +12,7 @@ export type _SyncFields = { account_id: T extends true ? string : null; bank: T extends true ? string : null; bankName: T extends true ? string : null; - bankId: T extends true ? number : null; + bankId: T extends true ? string : null; mask: T extends true ? string : null; // end of bank account number official_name: T extends true ? string : null; balance_current: T extends true ? number : null; diff --git a/upcoming-release-notes/6140.md b/upcoming-release-notes/6140.md new file mode 100644 index 0000000000..a1fa6b8d49 --- /dev/null +++ b/upcoming-release-notes/6140.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Migrate account state management from Redux to React Query with updated hooks.