Move redux state to react-query - account states

This commit is contained in:
Joel Jeremy Marquez
2025-11-13 16:20:35 -08:00
parent 89d5b7b6ad
commit 751e886796
37 changed files with 1420 additions and 1008 deletions

View File

@@ -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/fetch';
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<AccountEntity['id']>;
updatedAccounts: Array<AccountEntity['id']>;
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<TransactionEntity['id']>,
resMatchedTransactions: Array<TransactionEntity['id']>,
resUpdatedAccounts: Array<AccountEntity['id']>,
) {
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<TransactionEntity['id']> = [];
const matchedTransactions: Array<TransactionEntity['id']> = [];
const updatedAccounts: Array<AccountEntity['id']> = [];
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;
}

View File

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

View File

@@ -0,0 +1,956 @@
import { useTranslation } from 'react-i18next';
import {
useQueryClient,
useMutation,
type QueryClient,
} from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/fetch';
import { type SyncResponseWithErrors } from 'loot-core/server/accounts/app';
import {
type SyncServerGoCardlessAccount,
type SyncServerPluggyAiAccount,
type SyncServerSimpleFinAccount,
type AccountEntity,
type CategoryEntity,
type 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, useSelector } from '@desktop-client/redux';
import { type AppDispatch } from '@desktop-client/redux/store';
import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice';
type CreateAccountPayload = {
name: string;
balance: number;
offBudget: boolean;
};
export function useCreateAccountMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({ name, balance, offBudget }: CreateAccountPayload) => {
const id = await send('account-create', {
name,
balance,
offBudget,
});
return id;
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error creating account:', error);
dispatchErrorNotification(
t('There was an error creating the account. Please try again.'),
);
throw 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();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({
id,
transferAccountId,
categoryId,
forced,
}: CloseAccountPayload) => {
await send('account-close', {
id,
transferAccountId: transferAccountId || undefined,
categoryId: categoryId || undefined,
forced,
});
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error closing account:', error);
dispatchErrorNotification(
t('There was an error closing the account. Please try again.'),
);
throw error;
},
});
}
type ReopenAccountPayload = {
id: AccountEntity['id'];
};
export function useReopenAccountMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({ id }: ReopenAccountPayload) => {
await send('account-reopen', { id });
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error re-opening account:', error);
dispatchErrorNotification(
t('There was an error re-opening the account. Please try again.'),
);
throw error;
},
});
}
type UpdateAccountPayload = {
account: AccountEntity;
};
export function useUpdateAccountMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({ account }: UpdateAccountPayload) => {
await send('account-update', account);
return account;
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error updating account:', error);
dispatchErrorNotification(
t('There was an error updating the account. Please try again.'),
);
throw error;
},
});
}
type MoveAccountPayload = {
id: AccountEntity['id'];
targetId: AccountEntity['id'] | null;
};
export function useMoveAccountMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({ id, targetId }: MoveAccountPayload) => {
await send('account-move', { id, targetId });
// TODO: Change to a call to queryClient.invalidateQueries
// once payees have been moved to react-query.
dispatch(markPayeesDirty());
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error moving account:', error);
dispatchErrorNotification(
t('There was an error moving the account. Please try again.'),
);
throw error;
},
});
}
type ImportPreviewTransactionsPayload = {
accountId: string;
transactions: TransactionEntity[];
};
export function useImportPreviewTransactionsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
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: invalidateAccountLists,
onError: error => {
console.error('Error importing preview transactions to account:', error);
dispatchErrorNotification(
t(
'There was an error importing preview transactions to the account. Please try again.',
),
);
throw error;
},
});
}
type ImportTransactionsPayload = {
accountId: string;
transactions: TransactionEntity[];
reconcile: boolean;
};
export function useImportTransactionsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
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: invalidateAccountLists,
onError: error => {
console.error('Error importing transactions to account:', error);
dispatchErrorNotification(
t(
'There was an error importing transactions to the account. Please try again.',
),
);
throw error;
},
});
}
type UnlinkAccountPayload = {
id: AccountEntity['id'];
};
export function useUnlinkAccountMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({ id }: UnlinkAccountPayload) => {
await send('account-unlink', { id });
dispatch(markAccountSuccess({ id }));
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error unlinking account:', error);
dispatchErrorNotification(
t('There was an error unlinking the account. Please try again.'),
);
throw 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();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({
requisitionId,
account,
upgradingId,
offBudget,
startingDate,
startingBalance,
}: LinkAccountPayload) => {
await send('gocardless-accounts-link', {
requisitionId,
account,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
// TODO: Change to a call to queryClient.invalidateQueries
// once payees have been moved to react-query.
dispatch(markPayeesDirty());
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error linking account:', error);
dispatchErrorNotification(
t('There was an error linking the account. Please try again.'),
);
throw error;
},
});
}
type LinkAccountSimpleFinPayload = LinkAccountBasePayload & {
externalAccount: SyncServerSimpleFinAccount;
};
export function useLinkAccountSimpleFinMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
}: LinkAccountSimpleFinPayload) => {
await send('simplefin-accounts-link', {
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
// TODO: Change to a call to queryClient.invalidateQueries
// once payees have been moved to react-query.
dispatch(markPayeesDirty());
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error linking account to SimpleFIN:', error);
dispatchErrorNotification(
t(
'There was an error linking the account to SimpleFIN. Please try again.',
),
);
throw error;
},
});
}
type LinkAccountPluggyAiPayload = LinkAccountBasePayload & {
externalAccount: SyncServerPluggyAiAccount;
};
export function useLinkAccountPluggyAiMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
return useMutation({
mutationFn: async ({
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
}: LinkAccountPluggyAiPayload) => {
await send('pluggyai-accounts-link', {
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
// TODO: Change to a call to queryClient.invalidateQueries
// once payees have been moved to react-query.
dispatch(markPayeesDirty());
},
onSuccess: invalidateAccountLists,
onError: error => {
console.error('Error linking account to PluggyAI:', error);
dispatchErrorNotification(
t(
'There was an error linking the account to PluggyAI. Please try again.',
),
);
throw error;
},
});
}
type SyncAccountsPayload = {
id?: AccountEntity['id'] | undefined;
};
export function useSyncAccountsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
const accounts = useAccounts();
const accountState = useSelector(state => state.account);
return useMutation({
mutationFn: async ({ id }: SyncAccountsPayload) => {
const { accountsSyncing } = accountState;
// 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<TransactionEntity['id']> = [];
const matchedTransactions: Array<TransactionEntity['id']> = [];
const updatedAccounts: Array<AccountEntity['id']> = [];
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: invalidateAccountLists,
onError: error => {
console.error('Error linking account to PluggyAI:', error);
dispatchErrorNotification(
t(
'There was an error linking the account to PluggyAI. Please try again.',
),
);
throw error;
},
});
}
function handleSyncResponse(
accountId: AccountEntity['id'],
res: SyncResponseWithErrors,
dispatch: AppDispatch,
queryClient: QueryClient,
resNewTransactions: Array<TransactionEntity['id']>,
resMatchedTransactions: Array<TransactionEntity['id']>,
resUpdatedAccounts: Array<AccountEntity['id']>,
) {
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);
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
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 invalidateAccountLists = () => {
queryClient.invalidateQueries({
queryKey: accountQueries.lists(),
});
};
const dispatchErrorNotification = (message: string) => {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
},
}),
);
};
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: invalidateAccountLists,
onError: error => {
console.error('Error linking account to PluggyAI:', error);
dispatchErrorNotification(
t(
'There was an error linking the account to PluggyAI. Please try again.',
),
);
throw error;
},
});
}

View File

@@ -0,0 +1,30 @@
import { queryOptions } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/fetch';
import { type AccountEntity } from 'loot-core/types/models';
export const accountQueries = {
all: () => ['accounts'],
lists: () => [...accountQueries.all(), 'lists'],
list: () =>
queryOptions<AccountEntity[]>({
queryKey: [...accountQueries.lists()],
queryFn: async () => {
const accounts: AccountEntity[] = await send('accounts-get');
return accounts;
},
placeholderData: [],
// Manually invalidated when categories change
staleTime: Infinity,
}),
listOnBudget: () =>
queryOptions<AccountEntity[]>({
...accountQueries.list(),
select: accounts => accounts.filter(account => !!!account.offbudget),
}),
listOffBudget: () =>
queryOptions<AccountEntity[]>({
...accountQueries.list(),
select: accounts => accounts.filter(account => !!account.offbudget),
}),
};

View File

@@ -3,10 +3,8 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { send } from 'loot-core/platform/client/fetch';
import { getUploadError } from 'loot-core/shared/errors';
import type { AccountEntity } from 'loot-core/types/models';
import type { AtLeastOne } from 'loot-core/types/util';
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<AppState>;
@@ -195,7 +157,6 @@ export const actions = {
updateApp,
resetSync,
sync,
syncAndDownload,
getLatestAppVersion,
};

View File

@@ -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(

View File

@@ -40,13 +40,13 @@ 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';
import type { SavedFilter } from '@desktop-client/components/filters/SavedFilterMenuButton';
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';
import { useAccountPreviewTransactions } from '@desktop-client/hooks/useAccountPreviewTransactions';
@@ -244,7 +244,12 @@ type AccountInternalProps = {
accountsSyncing: string[];
dispatch: AppDispatch;
onSetTransfer: ReturnType<typeof useTransactionBatchActions>['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[];
@@ -567,9 +572,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 () => {
@@ -755,7 +758,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: '' });
}
};
@@ -804,7 +807,7 @@ class AccountInternal extends PureComponent<
accountName: account.name,
isViewBankSyncSettings: false,
onUnlink: () => {
this.props.dispatch(unlinkAccount({ id: accountId }));
this.props.onUnlinkAccount(accountId);
},
},
},
@@ -815,7 +818,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);
@@ -1022,11 +1025,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,
@@ -1996,6 +1995,22 @@ export function Account() {
[params.id],
);
const reopenAccount = useReopenAccountMutation();
const onReopenAccount = (id: AccountEntity['id']) =>
reopenAccount.mutate({ id });
const updateAccount = useUpdateAccountMutation();
const onUpdateAccount = (account: AccountEntity) =>
updateAccount.mutate({ account });
const unlinkAccount = useUnlinkAccountMutation();
const onUnlinkAccount = (id: AccountEntity['id']) =>
unlinkAccount.mutate({ id });
const syncAndDownload = useSyncAndDownloadMutation();
const onSyncAndDownload = (id?: AccountEntity['id']) =>
syncAndDownload.mutate({ id });
return (
<SchedulesProvider query={schedulesQuery}>
<SplitsExpandedProvider
@@ -2032,6 +2047,10 @@ export function Account() {
categoryId={location?.state?.categoryId}
location={location}
savedFilters={savedFiters}
onReopenAccount={onReopenAccount}
onUpdateAccount={onUpdateAccount}
onUnlinkAccount={onUnlinkAccount}
onSyncAndDownload={onSyncAndDownload}
/>
</SplitsExpandedProvider>
</SchedulesProvider>

View File

@@ -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) {

View File

@@ -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,
},
);
},
},
},

View File

@@ -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 updateAccount = useUpdateAccountMutation();
const onSave = useCallback(
(account: AccountEntity) => {
dispatch(updateAccount({ account }));
updateAccount.mutate({ 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 reopenAccount = useReopenAccountMutation();
const onReopenAccount = useCallback(() => {
dispatch(reopenAccount({ id: account.id }));
}, [account.id, dispatch]);
reopenAccount.mutate({ id: account.id });
}, [account.id, reopenAccount]);
const [showRunningBalances, setShowRunningBalances] = useSyncedPref(
`show-balances-${account.id}`,

View File

@@ -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(
() =>

View File

@@ -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 ? (
<View
style={{
backgroundColor: isPending
? theme.sidebarItemBackgroundPending
: isFailed
? theme.sidebarItemBackgroundFailed
: theme.sidebarItemBackgroundPositive,
marginRight: '8px',
width: 8,
flexShrink: 0,
height: 8,
borderRadius: 8,
opacity: isConnected ? 1 : 0,
}}
/>
) : null
}
{account.bankId ? (
<View
style={{
backgroundColor: isPending
? theme.sidebarItemBackgroundPending
: isFailed
? theme.sidebarItemBackgroundFailed
: theme.sidebarItemBackgroundPositive,
marginRight: '8px',
width: 8,
flexShrink: 0,
height: 8,
borderRadius: 8,
opacity: isConnected ? 1 : 0,
}}
/>
) : null}
<TextOneLine
style={{
...styles.text,
@@ -410,7 +409,8 @@ const AccountList = forwardRef<HTMLDivElement, AccountListProps>(
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<HTMLDivElement, AccountListProps>(
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<HTMLDivElement, AccountListProps>(
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 (
<View style={{ flex: 1 }}>

View File

@@ -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'),
},
);
}
},
},

View File

@@ -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';
@@ -91,6 +91,8 @@ export function CloseAccountModal({
}
: {};
const closeAccount = useCloseAccountMutation();
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -107,13 +109,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;
};
@@ -275,12 +276,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 }}

View File

@@ -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<HTMLFormElement>) => {
event.preventDefault();
@@ -65,14 +67,18 @@ export function CreateLocalAccountModal() {
if (!nameError && !balanceError) {
dispatch(closeModal());
const id = await dispatch(
createAccount({
createAccount.mutate(
{
name,
balance: toRelaxedNumber(balance),
offBudget: offbudget,
}),
).unwrap();
navigate('/accounts/' + id);
},
{
onSuccess: id => {
navigate('/accounts/' + id);
},
},
);
}
};
return (

View File

@@ -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({
await 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)
await 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(() => {

View File

@@ -21,13 +21,15 @@ import type {
} from 'loot-core/types/models';
import {
linkAccount,
linkAccountPluggyAi,
linkAccountSimpleFin,
unlinkAccount,
} from '@desktop-client/accounts/accountsSlice';
import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete';
import type { AutocompleteItem } from '@desktop-client/components/autocomplete/Autocomplete';
useLinkAccountMutation,
useLinkAccountPluggyAiMutation,
useLinkAccountSimpleFinMutation,
useUnlinkAccountMutation,
} from '@desktop-client/accounts';
import {
Autocomplete,
type AutocompleteItem,
} from '@desktop-client/components/autocomplete/Autocomplete';
import {
Modal,
ModalCloseButton,
@@ -154,6 +156,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 +169,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 +196,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,
});
}
},
);

View File

@@ -2,11 +2,14 @@ import React from 'react';
import { Provider } from 'react-redux';
import { theme } from '@actual-app/components/theme';
import { QueryClient } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { Change } from './Change';
import { store } from '@desktop-client/redux/store';
import { configureAppStore } from '@desktop-client/redux/store';
const store = configureAppStore({ queryClient: new QueryClient() });
describe('Change', () => {
it('renders a positive amount with a plus sign and positive color', () => {

View File

@@ -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<FieldName extends SheetFields<'account'>>({
window.matchMedia('(hover: none)').matches ||
window.matchMedia('(pointer: coarse)').matches;
const needsTooltip = !!account?.id && !isTouchDevice;
const reopenAccount = useReopenAccountMutation();
const updateAccount = useUpdateAccountMutation();
const accountRow = (
<View
@@ -222,14 +224,12 @@ export function Account<FieldName extends SheetFields<'account'>>({
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<FieldName extends SheetFields<'account'>>({
break;
}
case 'reopen': {
dispatch(reopenAccount({ id: account.id }));
reopenAccount.mutate({ id: account.id });
break;
}
case 'rename': {

View File

@@ -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 = () => {

View File

@@ -4,7 +4,7 @@ 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 { 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);

View File

@@ -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;
}

View File

@@ -1,20 +1,8 @@
import { useEffect } from 'react';
import { useInitialMount } from './useInitialMount';
import { getAccounts } from '@desktop-client/accounts/accountsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { useAccountsQuery } from './useAccountsQuery';
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 = useAccountsQuery();
// TODO: Update to return query states (e.g. isFetching, isError, etc)
// so clients can handle loading and error states appropriately.
return query.data ?? [];
}

View File

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

View File

@@ -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 ?? [];
}

View File

@@ -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 ?? [];
}

View File

@@ -26,12 +26,17 @@ import * as notificationsSlice from './notifications/notificationsSlice';
import * as payeesSlice from './payees/payeesSlice';
import * as prefsSlice from './prefs/prefsSlice';
import { aqlQuery } from './queries/aqlQuery';
import { store } from './redux/store';
import { configureAppStore } from './redux/store';
import * as tagsSlice from './tags/tagsSlice';
import * as transactionsSlice from './transactions/transactionsSlice';
import { redo, undo } from './undo';
import * as usersSlice from './users/usersSlice';
const queryClient = new QueryClient();
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
const store = configureAppStore({ queryClient });
const boundActions = bindActionCreators(
{
...accountsSlice.actions,
@@ -82,21 +87,18 @@ window.$send = send;
window.$query = aqlQuery;
window.$q = q;
const queryClient = new QueryClient();
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<Provider store={store}>
<ServerProvider>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<ServerProvider>
<AuthProvider>
<App />
</QueryClientProvider>
</AuthProvider>
</ServerProvider>
</Provider>,
</AuthProvider>
</ServerProvider>
</Provider>
</QueryClientProvider>,
);
declare global {

View File

@@ -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';
@@ -591,10 +592,7 @@ type OpenAccountCloseModalPayload = {
export const openAccountCloseModal = createAppAsyncThunk(
`${sliceName}/openAccountCloseModal`,
async (
{ accountId }: OpenAccountCloseModalPayload,
{ dispatch, getState },
) => {
async ({ accountId }: OpenAccountCloseModalPayload, { dispatch, extra }) => {
const {
balance,
numTransactions,
@@ -604,9 +602,10 @@ export const openAccountCloseModal = createAppAsyncThunk(
id: accountId,
},
);
const account = getState().account.accounts.find(
acct => acct.id === accountId,
);
const queryClient = extra.queryClient;
const accounts =
queryClient.getQueryData(accountQueries.list().queryKey) ?? [];
const account = accounts.find(acct => acct.id === accountId);
if (!account) {
throw new Error(`Account with ID ${accountId} does not exist.`);

View File

@@ -7,11 +7,17 @@ import {
import { createAsyncThunk } from '@reduxjs/toolkit';
import type { AppDispatch, AppStore, RootState } from './store';
import {
type ExtraArguments,
type AppDispatch,
type AppStore,
type RootState,
} from './store';
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState;
dispatch: AppDispatch;
extra: ExtraArguments;
}>();
export const useStore = useReduxStore.withTypes<AppStore>();

View File

@@ -2,72 +2,19 @@ import React from 'react';
import type { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { QueryClient } from '@tanstack/react-query';
import type { store as realStore } from './store';
import { configureAppStore, type AppStore } from './store';
import {
name as accountsSliceName,
reducer as accountsSliceReducer,
} from '@desktop-client/accounts/accountsSlice';
import {
name as appSliceName,
reducer as appSliceReducer,
} from '@desktop-client/app/appSlice';
import {
name as budgetfilesSliceName,
reducer as budgetfilesSliceReducer,
} from '@desktop-client/budgetfiles/budgetfilesSlice';
import {
name as modalsSliceName,
reducer as modalsSliceReducer,
} from '@desktop-client/modals/modalsSlice';
import {
name as notificationsSliceName,
reducer as notificationsSliceReducer,
} from '@desktop-client/notifications/notificationsSlice';
import {
name as payeesSliceName,
reducer as payeesSliceReducer,
} from '@desktop-client/payees/payeesSlice';
import {
name as prefsSliceName,
reducer as prefsSliceReducer,
} from '@desktop-client/prefs/prefsSlice';
import {
name as tagsSliceName,
reducer as tagsSliceReducer,
} from '@desktop-client/tags/tagsSlice';
import {
name as transactionsSliceName,
reducer as transactionsSliceReducer,
} from '@desktop-client/transactions/transactionsSlice';
import {
name as usersSliceName,
reducer as usersSliceReducer,
} from '@desktop-client/users/usersSlice';
let mockQueryClient = new QueryClient();
const appReducer = combineReducers({
[accountsSliceName]: accountsSliceReducer,
[appSliceName]: appSliceReducer,
[budgetfilesSliceName]: budgetfilesSliceReducer,
[modalsSliceName]: modalsSliceReducer,
[notificationsSliceName]: notificationsSliceReducer,
[payeesSliceName]: payeesSliceReducer,
[prefsSliceName]: prefsSliceReducer,
[transactionsSliceName]: transactionsSliceReducer,
[tagsSliceName]: tagsSliceReducer,
[usersSliceName]: usersSliceReducer,
});
export let mockStore: typeof realStore = configureStore({
reducer: appReducer,
export let mockStore: AppStore = configureAppStore({
queryClient: mockQueryClient,
});
export function resetMockStore() {
mockStore = configureStore({
reducer: appReducer,
});
mockQueryClient = new QueryClient();
mockStore = configureAppStore({ queryClient: mockQueryClient });
}
export function TestProvider({ children }: { children: ReactNode }) {

View File

@@ -4,6 +4,7 @@ import {
createListenerMiddleware,
isRejected,
} from '@reduxjs/toolkit';
import { type QueryClient } from '@tanstack/react-query';
import {
name as accountsSliceName,
@@ -77,16 +78,28 @@ notifyOnRejectedActionsMiddleware.startListening({
},
});
export const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
// TODO: Fix this in a separate PR. Remove non-serializable states in the store.
serializableCheck: false,
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
});
export function configureAppStore({
queryClient,
}: {
queryClient: QueryClient;
}) {
return configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
// TODO: Fix this in a separate PR. Remove non-serializable states in the store.
serializableCheck: false,
thunk: {
extraArgument: { queryClient } as ExtraArguments,
},
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
});
}
export type AppStore = typeof store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type GetRootState = typeof store.getState;
export type AppStore = ReturnType<typeof configureAppStore>;
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
export type GetRootState = AppStore['getState'];
export type ExtraArguments = {
queryClient: QueryClient;
};

View File

@@ -4,7 +4,7 @@ import { t } from 'i18next';
import { listen, send } from 'loot-core/platform/client/fetch';
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;

View File

@@ -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',

View File

@@ -89,8 +89,31 @@ async function updateAccount({
return {};
}
async function getAccounts() {
return db.getAccounts();
async function getAccounts(): Promise<AccountEntity[]> {
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({

View File

@@ -570,8 +570,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));
};

View File

@@ -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 = {

View File

@@ -12,7 +12,7 @@ export type _SyncFields<T> = {
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;