Compare commits

...

21 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
3c4f0fff58 Add default data to usePayees usages using inline destructuring
Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>
2026-02-10 19:31:56 +00:00
copilot-swe-agent[bot]
acadee2a5c Initial plan 2026-02-10 19:15:46 +00:00
Joel Jeremy Marquez
1a1c7447ad Fix lint errors 2026-02-10 18:33:15 +00:00
Joel Jeremy Marquez
c8d7e4bc92 Replace usage of logger in desktop-client with console 2026-02-10 18:13:14 +00:00
github-actions[bot]
7b19600b29 Add release notes for PR #6880 2026-02-10 18:13:14 +00:00
Joel Jeremy Marquez
9804625b57 Move redux state to react-query - payees states 2026-02-10 18:13:14 +00:00
Joel Jeremy Marquez
10374316db Clear react query on closing of budget file similar to redux resetApp action 2026-02-10 18:12:56 +00:00
Joel Jeremy Marquez
bbd039e572 Fix lint errors 2026-02-10 16:30:19 +00:00
Joel Jeremy Marquez
c5db712481 Replace logger calls in desktop-client to console 2026-02-10 16:16:19 +00:00
Joel Jeremy Marquez
11837ddad5 [skip ci] Change category to Maintenance and update migration text 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
0830540168 Fix onbudget and offbudget displaying closed accounts 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
c22d445ae0 Fix TestProviders 2026-02-10 16:16:04 +00:00
autofix-ci[bot]
1cbacdd192 [autofix.ci] apply automated fixes 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
e678a69c70 Cleanup 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
6c75b4545d Coderabbit feedback 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
c42390ca9f Fix TestProviders 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
ac8d247def Fix lint error 2026-02-10 16:16:04 +00:00
github-actions[bot]
34f0c6c2e7 Add release notes for PR #6140 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
63de281d13 TestProviders 2026-02-10 16:16:04 +00:00
Joel Jeremy Marquez
751e886796 Move redux state to react-query - account states 2026-02-10 16:15:52 +00:00
Joel Jeremy Marquez
89d5b7b6ad Fix typecheck errors 2026-02-10 16:13:43 +00:00
83 changed files with 1668 additions and 1545 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,761 @@
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/fetch';
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 { payeeQueries } from '@desktop-client/payees';
import { useDispatch, useSelector } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice';
const sendThrow: typeof send = async function (name, args) {
const { data, error } = await send(name, args, { catchErrors: true });
if (error) {
throw error;
}
return data;
};
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 sendThrow('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,
);
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();
return useMutation({
mutationFn: async ({
id,
transferAccountId,
categoryId,
forced,
}: CloseAccountPayload) => {
await sendThrow('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,
);
throw 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 sendThrow('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,
);
throw error;
},
});
}
type UpdateAccountPayload = {
account: AccountEntity;
};
export function useUpdateAccountMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ account }: UpdateAccountPayload) => {
await sendThrow('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,
);
throw 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 sendThrow('account-move', { id, targetId });
invalidateQueries(queryClient, payeeQueries.lists());
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error moving account:', error);
dispatchErrorNotification(
dispatch,
t('There was an error moving the account. Please try again.'),
error,
);
throw 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 sendThrow(
'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,
);
throw 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 sendThrow('api/transactions-add', {
accountId,
transactions,
});
return true;
}
const {
errors = [],
added,
updated,
} = await sendThrow('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,
);
throw 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 sendThrow('account-unlink', { id });
dispatch(markAccountSuccess({ id }));
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error unlinking account:', error);
dispatchErrorNotification(
dispatch,
t('There was an error unlinking the account. Please try again.'),
error,
);
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();
return useMutation({
mutationFn: async ({
requisitionId,
account,
upgradingId,
offBudget,
startingDate,
startingBalance,
}: LinkAccountPayload) => {
await sendThrow('gocardless-accounts-link', {
requisitionId,
account,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
invalidateQueries(queryClient, payeeQueries.lists());
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error linking account:', error);
dispatchErrorNotification(
dispatch,
t('There was an error linking the account. Please try again.'),
error,
);
throw 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 sendThrow('simplefin-accounts-link', {
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
invalidateQueries(queryClient, payeeQueries.lists());
},
onSuccess: () => invalidateQueries(queryClient),
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,
);
throw 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 sendThrow('pluggyai-accounts-link', {
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
invalidateQueries(queryClient, payeeQueries.lists());
},
onSuccess: () => invalidateQueries(queryClient),
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,
);
throw error;
},
});
}
type SyncAccountsPayload = {
id?: AccountEntity['id'] | undefined;
};
export function useSyncAccountsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
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 sendThrow('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 sendThrow('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,
);
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);
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,
);
throw error;
},
});
}

View File

@@ -0,0 +1,42 @@
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 accounts change
staleTime: Infinity,
}),
listActive: () =>
queryOptions<AccountEntity[]>({
...accountQueries.list(),
select: accounts => accounts.filter(account => !account.closed),
}),
listClosed: () =>
queryOptions<AccountEntity[]>({
...accountQueries.list(),
select: accounts => accounts.filter(account => !!account.closed),
}),
listOnBudget: () =>
queryOptions<AccountEntity[]>({
...accountQueries.listActive(),
select: accounts =>
accounts.filter(account => !account.offbudget && !account.closed),
}),
listOffBudget: () =>
queryOptions<AccountEntity[]>({
...accountQueries.listActive(),
select: accounts =>
accounts.filter(account => !!account.offbudget && !account.closed),
}),
};

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 { 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,14 +7,13 @@ import { v4 as uuidv4 } from 'uuid';
import { sendCatch } from 'loot-core/platform/client/fetch';
import type { send } from 'loot-core/platform/client/fetch';
import { logger } from 'loot-core/platform/server/log';
import type { IntegerAmount } from 'loot-core/shared/util';
import type {
CategoryEntity,
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,9 +148,8 @@ 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 ?? [];
@@ -230,7 +228,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 +272,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 +297,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 +339,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 +387,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 +470,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 +497,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 +826,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.'),

View File

@@ -100,10 +100,11 @@ export const loadBudget = createAppAsyncThunk(
export const closeBudget = createAppAsyncThunk(
`${sliceName}/closeBudget`,
async (_, { dispatch, getState }) => {
async (_, { dispatch, getState, extra: { queryClient } }) => {
const prefs = getState().prefs.local;
if (prefs && prefs.id) {
await dispatch(resetApp());
queryClient.clear();
await dispatch(setAppState({ loadingText: t('Closing...') }));
await send('close-budget');
await dispatch(setAppState({ loadingText: null }));
@@ -116,10 +117,11 @@ export const closeBudget = createAppAsyncThunk(
export const closeBudgetUI = createAppAsyncThunk(
`${sliceName}/closeBudgetUI`,
async (_, { dispatch, getState }) => {
async (_, { dispatch, getState, extra: { queryClient } }) => {
const prefs = getState().prefs.local;
if (prefs && prefs.id) {
await dispatch(resetApp());
queryClient.clear();
}
},
);

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

@@ -37,7 +37,6 @@ import {
useSelected,
} from '@desktop-client/hooks/useSelected';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { getPayees } from '@desktop-client/payees/payeesSlice';
import { useDispatch } from '@desktop-client/redux';
export type FilterData = {
@@ -135,7 +134,7 @@ export function ManageRules({
query: useMemo(() => q('schedules').select('*'), []),
});
const { list: categories } = useCategories();
const payees = usePayees();
const { data: payees } = usePayees();
const accounts = useAccounts();
const filterData = useMemo(
() => ({
@@ -192,8 +191,6 @@ export function ManageRules({
async function loadData() {
await loadRules();
setLoading(false);
await dispatch(getPayees());
}
if (payeeId) {

View File

@@ -30,6 +30,7 @@ import type { IntegerAmount } from 'loot-core/shared/util';
import type {
AccountEntity,
NewRuleEntity,
PayeeEntity,
RuleActionEntity,
RuleConditionEntity,
TransactionEntity,
@@ -40,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';
@@ -74,7 +75,7 @@ import {
replaceModal,
} from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { createPayee, getPayees } from '@desktop-client/payees/payeesSlice';
import { useCreatePayeeMutation } from '@desktop-client/payees';
import * as queries from '@desktop-client/queries';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { pagedQuery } from '@desktop-client/queries/pagedQuery';
@@ -238,13 +239,19 @@ type AccountInternalProps = {
location: ReturnType<typeof useLocation>;
failedAccounts: ReturnType<typeof useFailedAccounts>;
dateFormat: ReturnType<typeof useDateFormat>;
payees: ReturnType<typeof usePayees>;
payees: PayeeEntity[];
categoryGroups: ReturnType<typeof useCategories>['grouped'];
hideFraction: boolean;
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;
onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>;
};
type AccountInternalState = {
search: string;
filterConditions: ConditionEntity[];
@@ -375,7 +382,6 @@ class AccountInternal extends PureComponent<
// Important that any async work happens last so that the
// listeners are set up synchronously
await this.props.dispatch(getPayees());
await this.fetchTransactions(this.state.filterConditions);
// If there is a pending undo, apply it immediately (this happens
@@ -567,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 () => {
@@ -755,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: '' });
}
};
@@ -804,7 +808,7 @@ class AccountInternal extends PureComponent<
accountName: account.name,
isViewBankSyncSettings: false,
onUnlink: () => {
this.props.dispatch(unlinkAccount({ id: accountId }));
this.props.onUnlinkAccount(accountId);
},
},
},
@@ -815,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);
@@ -941,7 +945,7 @@ class AccountInternal extends PureComponent<
onCreatePayee = async (name: string) => {
const trimmed = name.trim();
if (trimmed !== '') {
return this.props.dispatch(createPayee({ name })).unwrap();
return await this.props.onCreatePayee(name);
}
return null;
};
@@ -1022,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,
@@ -1965,7 +1965,7 @@ export function Account() {
state => state.transactions.matchedTransactions,
);
const accounts = useAccounts();
const payees = usePayees();
const { data: payees = [] } = usePayees();
const failedAccounts = useFailedAccounts();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const [hideFraction] = useSyncedPref('hideFraction');
@@ -1996,6 +1996,26 @@ 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 });
const createPayee = useCreatePayeeMutation();
const onCreatePayee = (name: PayeeEntity['name']) =>
createPayee.mutateAsync({ name });
return (
<SchedulesProvider query={schedulesQuery}>
<SplitsExpandedProvider
@@ -2032,6 +2052,11 @@ export function Account() {
categoryId={location?.state?.categoryId}
location={location}
savedFilters={savedFiters}
onReopenAccount={onReopenAccount}
onUpdateAccount={onUpdateAccount}
onUnlinkAccount={onUnlinkAccount}
onSyncAndDownload={onSyncAndDownload}
onCreatePayee={onCreatePayee}
/>
</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

@@ -10,7 +10,7 @@ import type { AccountEntity } from 'loot-core/types/models';
import { ReconcileMenu, ReconcilingMessage } from './Reconcile';
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('@desktop-client/hooks/useSheetValue', () => ({
useSheetValue: vi.fn(),
@@ -40,14 +40,14 @@ describe('ReconcilingMessage math & UI', () => {
const onCreateTransaction = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcilingMessage
balanceQuery={makeBalanceQuery()}
targetBalance={5000}
onDone={onDone}
onCreateTransaction={onCreateTransaction}
/>
</TestProvider>,
</TestProviders>,
);
expect(screen.getByText('All reconciled!')).toBeInTheDocument();
@@ -67,14 +67,14 @@ describe('ReconcilingMessage math & UI', () => {
const onCreateTransaction = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcilingMessage
balanceQuery={makeBalanceQuery()}
targetBalance={10000}
onDone={vi.fn()}
onCreateTransaction={onCreateTransaction}
/>
</TestProvider>,
</TestProviders>,
);
// Formatted amounts present
@@ -95,14 +95,14 @@ describe('ReconcilingMessage math & UI', () => {
const onCreateTransaction = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcilingMessage
balanceQuery={makeBalanceQuery()}
targetBalance={10000}
onDone={vi.fn()}
onCreateTransaction={onCreateTransaction}
/>
</TestProvider>,
</TestProviders>,
);
expect(screen.getByText('120.00')).toBeInTheDocument();
@@ -133,13 +133,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
const input = screen.getByRole('textbox');
@@ -162,13 +162,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
const input = screen.getByRole('textbox');
@@ -200,13 +200,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
connectedAccount.balance_current = 4321;
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={connectedAccount}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
// Fill from last synced value (43.21)
@@ -223,13 +223,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onReconcile = vi.fn();
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
const input = screen.getByRole('textbox');
@@ -247,13 +247,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onReconcile = vi.fn();
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
await userEvent.click(screen.getByRole('button', { name: 'Reconcile' }));

View File

@@ -10,8 +10,8 @@ import { PayeeAutocomplete } from './PayeeAutocomplete';
import type { PayeeAutocompleteProps } from './PayeeAutocomplete';
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
import { useCommonPayees } from '@desktop-client/hooks/usePayees';
import { TestProvider } from '@desktop-client/redux/mock';
import { createTestQueryClient, TestProviders } from '@desktop-client/mocks';
import { payeeQueries } from '@desktop-client/payees';
const PAYEE_SELECTOR = '[data-testid][role=option]';
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';
@@ -65,32 +65,6 @@ function extractPayeesAndHeaderNames(screen: Screen) {
.map(firstOrIncorrect);
}
function renderPayeeAutocomplete(
props?: Partial<PayeeAutocompleteProps>,
): HTMLElement {
const autocompleteProps = {
...defaultProps,
...props,
};
render(
<TestProvider>
<AuthProvider>
<div data-testid="autocomplete-test">
<PayeeAutocomplete
{...autocompleteProps}
onSelect={vi.fn()}
type="single"
value={null}
embedded={false}
/>
</div>
</AuthProvider>
</TestProvider>,
);
return screen.getByTestId('autocomplete-test');
}
// Not good, see `Autocomplete.js` for details
function waitForAutocomplete() {
return new Promise(resolve => setTimeout(resolve, 0));
@@ -104,20 +78,43 @@ async function clickAutocomplete(autocomplete: HTMLElement) {
await waitForAutocomplete();
}
vi.mock('../../hooks/usePayees', () => ({
useCommonPayees: vi.fn(),
usePayees: vi.fn().mockReturnValue([]),
}));
function firstOrIncorrect(id: string | null): string {
return id?.split('-', 1)[0] || 'incorrect';
}
describe('PayeeAutocomplete.getPayeeSuggestions', () => {
const queryClient = createTestQueryClient();
beforeEach(() => {
vi.mocked(useCommonPayees).mockReturnValue([]);
queryClient.setQueryData(payeeQueries.listCommon().queryKey, []);
});
function renderPayeeAutocomplete(
props?: Partial<PayeeAutocompleteProps>,
): HTMLElement {
const autocompleteProps = {
...defaultProps,
...props,
};
render(
<TestProviders queryClient={queryClient}>
<AuthProvider>
<div data-testid="autocomplete-test">
<PayeeAutocomplete
{...autocompleteProps}
onSelect={vi.fn()}
type="single"
value={null}
embedded={false}
/>
</div>
</AuthProvider>
</TestProviders>,
);
return screen.getByTestId('autocomplete-test');
}
test('favorites get sorted alphabetically', async () => {
const autocomplete = renderPayeeAutocomplete();
await clickAutocomplete(autocomplete);
@@ -145,7 +142,7 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
makePayee('Steve'),
makePayee('Tony'),
];
vi.mocked(useCommonPayees).mockReturnValue([
queryClient.setQueryData(payeeQueries.listCommon().queryKey, [
makePayee('Bruce'),
makePayee('Natasha'),
makePayee('Steve'),
@@ -183,7 +180,7 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
makePayee('Steve'),
makePayee('Tony', { favorite: true }),
];
vi.mocked(useCommonPayees).mockReturnValue([
queryClient.setQueryData(payeeQueries.listCommon().queryKey, [
makePayee('Bruce'),
makePayee('Natasha'),
makePayee('Steve'),

View File

@@ -31,12 +31,12 @@ import {
import { ItemHeader } from './ItemHeader';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCommonPayees, usePayees } from '@desktop-client/hooks/usePayees';
import { useCommonPayees } from '@desktop-client/hooks/useCommonPayees';
import { usePayees } from '@desktop-client/hooks/usePayees';
import {
createPayee,
getActivePayees,
} from '@desktop-client/payees/payeesSlice';
import { useDispatch } from '@desktop-client/redux';
useCreatePayeeMutation,
} from '@desktop-client/payees';
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
@@ -347,11 +347,12 @@ export function PayeeAutocomplete({
}: PayeeAutocompleteProps) {
const { t } = useTranslation();
const commonPayees = useCommonPayees();
const retrievedPayees = usePayees();
const { data: commonPayees } = useCommonPayees();
const { data: retrievedPayees = [] } = usePayees();
if (!payees) {
payees = retrievedPayees;
}
const createPayeeMutation = useCreatePayeeMutation();
const cachedAccounts = useAccounts();
if (!accounts) {
@@ -391,14 +392,12 @@ export function PayeeAutocomplete({
showInactivePayees,
]);
const dispatch = useDispatch();
async function handleSelect(idOrIds, rawInputValue) {
if (!clearOnBlur) {
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
} else {
const create = payeeName =>
dispatch(createPayee({ name: payeeName })).unwrap();
createPayeeMutation.mutateAsync({ name: payeeName });
if (Array.isArray(idOrIds)) {
idOrIds = await Promise.all(

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

@@ -26,7 +26,7 @@ export function MobilePayeeEditPage() {
const { id } = useParams<{ id: string }>();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
const payees = usePayees();
const { data: payees = [] } = usePayees();
const [payee, setPayee] = useState<PayeeEntity | null>(null);
const [editedPayeeName, setEditedPayeeName] = useState('');

View File

@@ -8,16 +8,13 @@ import type { PayeeEntity } from 'loot-core/types/models';
import { MobilePayeesPage } from './MobilePayeesPage';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayeeRuleCounts } from '@desktop-client/hooks/usePayeeRuleCounts';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { TestProvider } from '@desktop-client/redux/mock';
import { createTestQueryClient, TestProviders } from '@desktop-client/mocks';
import { payeeQueries } from '@desktop-client/payees';
vi.mock('@use-gesture/react', () => ({
useDrag: vi.fn().mockReturnValue(() => ({})),
}));
vi.mock('@desktop-client/hooks/useNavigate');
vi.mock('@desktop-client/hooks/usePayees');
vi.mock('@desktop-client/hooks/usePayeeRuleCounts');
const mockPayees: PayeeEntity[] = [
{
@@ -39,27 +36,28 @@ const mockPayees: PayeeEntity[] = [
describe('MobilePayeesPage', () => {
const mockNavigate = vi.fn();
const queryClient = createTestQueryClient();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useNavigate).mockReturnValue(mockNavigate);
vi.mocked(usePayees).mockImplementation(() => mockPayees);
vi.mocked(usePayeeRuleCounts).mockReturnValue({
ruleCounts: new Map([
queryClient.setQueryData(payeeQueries.list().queryKey, mockPayees);
queryClient.setQueryData(
payeeQueries.ruleCounts().queryKey,
new Map([
['payee-1', 2],
['payee-2', 0],
]),
isLoading: false,
refetch: vi.fn().mockResolvedValue(undefined),
});
);
});
const renderPayeesPage = () => {
return render(
<TestProvider>
<TestProviders queryClient={queryClient}>
<MobilePayeesPage />
</TestProvider>,
</TestProviders>,
);
};
@@ -156,8 +154,6 @@ describe('MobilePayeesPage', () => {
});
it('handles empty payee list', () => {
vi.mocked(usePayees).mockReturnValue([]);
renderPayeesPage();
// Page should render even with no payees

View File

@@ -18,19 +18,17 @@ import { usePayeeRuleCounts } from '@desktop-client/hooks/usePayeeRuleCounts';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { useDispatch } from '@desktop-client/redux';
export function MobilePayeesPage() {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const payees = usePayees();
const { data: payees = [], isPending } = usePayees();
const { showUndoNotification } = useUndo();
const [filter, setFilter] = useState('');
const { ruleCounts, isLoading: isRuleCountsLoading } = usePayeeRuleCounts();
const isLoading = useSelector(
s => s.payees.isPayeesLoading || s.payees.isCommonPayeesLoading,
);
const { data: ruleCounts = new Map(), isPending: isRuleCountsLoading } =
usePayeeRuleCounts();
const filteredPayees: PayeeEntity[] = useMemo(() => {
if (!filter) return payees;
@@ -141,7 +139,7 @@ export function MobilePayeesPage() {
payees={filteredPayees}
ruleCounts={ruleCounts}
isRuleCountsLoading={isRuleCountsLoading}
isLoading={isLoading}
isLoading={isPending}
onPayeePress={handlePayeePress}
onPayeeDelete={handlePayeeDelete}
onPayeeRuleAction={handlePayeeRuleAction}

View File

@@ -41,7 +41,7 @@ export function MobileRulesPage() {
query: useMemo(() => q('schedules').select('*'), []),
});
const { list: categories } = useCategories();
const payees = usePayees();
const { data: payees } = usePayees();
const accounts = useAccounts();
const filterData = useMemo(
() => ({

View File

@@ -45,7 +45,7 @@ export function MobileSchedulesPage() {
statuses,
} = useSchedules({ query: schedulesQuery });
const payees = usePayees();
const { data: payees = [] } = usePayees();
const accounts = useAccounts();
const filterIncludes = (str: string | null | undefined) =>

View File

@@ -1680,7 +1680,7 @@ type TransactionEditProps = Omit<
export const TransactionEdit = (props: TransactionEditProps) => {
const { list: categories } = useCategories();
const payees = usePayees();
const { data: payees = [] } = usePayees();
const lastTransaction = useSelector(
state => state.transactions.lastTransaction,
);

View File

@@ -337,7 +337,7 @@ function SelectedTransactionsFloatingActionBar({
const accounts = useAccounts();
const accountsById = useMemo(() => groupById(accounts), [accounts]);
const payees = usePayees();
const { data: payees = [] } = usePayees();
const payeesById = useMemo(() => groupById(payees), [payees]);
const { list: categories } = useCategories();

View File

@@ -89,7 +89,7 @@ export function TransactionListItem({
const { t } = useTranslation();
const { list: categories } = useCategories();
const payee = usePayee(transaction?.payee || '');
const { data: payee } = usePayee(transaction?.payee);
const displayPayee = useDisplayPayee({ transaction });
const account = useAccount(transaction?.account || '');

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

View File

@@ -3,7 +3,7 @@ import { vi } from 'vitest';
import { GoCardlessExternalMsgModal } from './GoCardlessExternalMsgModal';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('@desktop-client/hooks/useGlobalPref', () => ({
useGlobalPref: () => [null],
@@ -50,9 +50,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');
@@ -76,9 +76,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');
@@ -101,9 +101,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');
@@ -126,9 +126,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');

View File

@@ -11,6 +11,7 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { useQueryClient } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/fetch';
import type { ParseFileOptions } from 'loot-core/server/transactions/import/parse-file';
@@ -32,9 +33,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,
@@ -49,8 +50,7 @@ import {
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useSyncedPrefs } from '@desktop-client/hooks/useSyncedPrefs';
import { reloadPayees } from '@desktop-client/payees/payeesSlice';
import { useDispatch } from '@desktop-client/redux';
import { payeeQueries } from '@desktop-client/payees';
function getFileType(filepath: string): string {
const m = filepath.match(/\.([^.]*)$/);
@@ -159,9 +159,9 @@ export function ImportTransactionsModal({
onImported,
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const dateFormat = useDateFormat() || ('MM/dd/yyyy' as const);
const [prefs, savePrefs] = useSyncedPrefs();
const dispatch = useDispatch();
const { list: categories } = useCategories();
const [multiplierAmount, setMultiplierAmount] = useState('');
@@ -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) {
queryClient.invalidateQueries(payeeQueries.list());
}
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(() => {

View File

@@ -30,7 +30,7 @@ export function MergeUnusedPayeesModal({
targetPayeeId,
}: MergeUnusedPayeesModalProps) {
const { t } = useTranslation();
const allPayees = usePayees();
const { data: allPayees = [] } = usePayees();
const modalStack = useSelector(state => state.modals.modalStack);
const isEditingRule = !!modalStack.find(m => m.name === 'edit-rule');
const dispatch = useDispatch();

View File

@@ -26,7 +26,7 @@ export function PayeeAutocompleteModal({
onClose,
}: PayeeAutocompleteModalProps) {
const { t } = useTranslation();
const payees = usePayees() || [];
const { data: payees = [] } = usePayees();
const accounts = useAccounts() || [];
const navigate = useNavigate();

View File

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

View File

@@ -1,4 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { listen, send } from 'loot-core/platform/client/fetch';
import * as undo from 'loot-core/platform/client/undo';
@@ -9,10 +11,11 @@ import type { NewRuleEntity, PayeeEntity } from 'loot-core/types/models';
import { ManagePayees } from './ManagePayees';
import { useOrphanedPayees } from '@desktop-client/hooks/useOrphanedPayees';
import { usePayeeRuleCounts } from '@desktop-client/hooks/usePayeeRuleCounts';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { getPayees, reloadPayees } from '@desktop-client/payees/payeesSlice';
import { payeeQueries } from '@desktop-client/payees';
import { useDispatch } from '@desktop-client/redux';
type ManagePayeesWithDataProps = {
@@ -22,24 +25,15 @@ type ManagePayeesWithDataProps = {
export function ManagePayeesWithData({
initialSelectedIds,
}: ManagePayeesWithDataProps) {
const payees = usePayees();
const queryClient = useQueryClient();
const { data: payees = [], refetch: refetchPayees } = usePayees();
const { data: orphanedPayees = [], refetch: refetchOrphanedPayees } =
useOrphanedPayees();
const dispatch = useDispatch();
const { ruleCounts, refetch: refetchRuleCounts } = usePayeeRuleCounts();
const [orphans, setOrphans] = useState<Array<Pick<PayeeEntity, 'id'>>>([]);
const refetchOrphanedPayees = useCallback(async () => {
const orphs = await send('payees-get-orphaned');
setOrphans(orphs);
}, []);
const { data: ruleCounts = new Map(), refetch: refetchRuleCounts } =
usePayeeRuleCounts();
useEffect(() => {
async function loadData() {
await dispatch(getPayees());
await refetchOrphanedPayees();
}
loadData();
const unlisten = listen('sync-event', async event => {
if (event.type === 'applied') {
if (event.tables.includes('rules')) {
@@ -51,7 +45,7 @@ export function ManagePayeesWithData({
return () => {
unlisten();
};
}, [dispatch, refetchRuleCounts, refetchOrphanedPayees]);
}, [dispatch, refetchRuleCounts]);
useEffect(() => {
async function onUndo({ tables, messages, meta }: UndoState) {
@@ -115,32 +109,40 @@ export function ManagePayeesWithData({
<ManagePayees
payees={payees}
ruleCounts={ruleCounts}
orphanedPayees={orphans}
orphanedPayees={orphanedPayees}
initialSelectedIds={initialSelectedIds}
onBatchChange={async (changes: Diff<PayeeEntity>) => {
await send('payees-batch-change', changes);
setOrphans(applyChanges(changes, orphans));
queryClient.setQueryData(
payeeQueries.listOrphaned().queryKey,
existing => applyChanges(changes, existing || []),
);
}}
onMerge={async ([targetId, ...mergeIds]) => {
await send('payees-merge', { targetId, mergeIds });
const targetIdIsOrphan = orphans.map(o => o.id).includes(targetId);
const targetIdIsOrphan = orphanedPayees
.map(o => o.id)
.includes(targetId);
const mergeIdsOrphans = mergeIds.filter(m =>
orphans.map(o => o.id).includes(m),
orphanedPayees.map(o => o.id).includes(m),
);
let filtedOrphans = orphans;
let filteredOrphans = orphanedPayees;
if (targetIdIsOrphan && mergeIdsOrphans.length !== mergeIds.length) {
// there is a non-orphan in mergeIds, target can be removed from orphan arr
filtedOrphans = filtedOrphans.filter(o => o.id !== targetId);
filteredOrphans = filteredOrphans.filter(o => o.id !== targetId);
}
filtedOrphans = filtedOrphans.filter(o => !mergeIds.includes(o.id));
filteredOrphans = filteredOrphans.filter(o => !mergeIds.includes(o.id));
// Refetch rule counts after merging
await refetchRuleCounts();
await dispatch(reloadPayees());
setOrphans(filtedOrphans);
refetchPayees();
queryClient.setQueryData(
payeeQueries.listOrphaned().queryKey,
filteredOrphans,
);
}}
onViewRules={onViewRules}
onCreateRule={onCreateRule}

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

@@ -119,7 +119,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
);
const accounts = useAccounts();
const payees = usePayees();
const { data: payees = [] } = usePayees();
const { grouped: categoryGroups } = useCategories();
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');

View File

@@ -470,7 +470,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
const balanceTypeOp: balanceTypeOpType =
ReportOptions.balanceTypeMap.get(balanceType) || 'totalDebts';
const sortByOp: sortByOpType = sortBy || 'desc';
const payees = usePayees();
const { data: payees = [] } = usePayees();
const accounts = useAccounts();
const hasWarning = calculateHasWarning(conditions, {

View File

@@ -79,7 +79,7 @@ function CustomReportListCardsInner({
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useWidgetCopyMenu(onCopy);
const payees = usePayees();
const { data: payees = [] } = usePayees();
const accounts = useAccounts();
const categories = useCategories();

View File

@@ -64,7 +64,6 @@ import {
useSelected,
} from '@desktop-client/hooks/useSelected';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { getPayees } from '@desktop-client/payees/payeesSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
import { disableUndo, enableUndo } from '@desktop-client/undo';
@@ -1022,8 +1021,6 @@ export function RuleEditor({
);
useEffect(() => {
dispatch(getPayees());
// Disable undo while this modal is open
disableUndo();
return () => enableUndo();

View File

@@ -10,9 +10,8 @@ import type { ScheduleEntity } from 'loot-core/types/models';
import { Value } from './Value';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { usePayeesById } from '@desktop-client/hooks/usePayees';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { getPayeesById } from '@desktop-client/payees/payeesSlice';
type ScheduleValueProps = {
value: ScheduleEntity;
@@ -20,8 +19,7 @@ type ScheduleValueProps = {
export function ScheduleValue({ value }: ScheduleValueProps) {
const { t } = useTranslation();
const payees = usePayees();
const byId = getPayeesById(payees);
const { data: byId = {} } = usePayeesById();
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules = [], isLoading } = useSchedules({ query: schedulesQuery });

View File

@@ -42,7 +42,7 @@ export function Value<T>({
const { t } = useTranslation();
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const payees = usePayees();
const { data: payees } = usePayees();
const { list: categories } = useCategories();
const accounts = useAccounts();
const valueStyle = {

View File

@@ -18,12 +18,11 @@ import {
ModalCloseButton,
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { usePayeesById } from '@desktop-client/hooks/usePayees';
import { useScheduleEdit } from '@desktop-client/hooks/useScheduleEdit';
import { useSelected } from '@desktop-client/hooks/useSelected';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { getPayeesById } from '@desktop-client/payees/payeesSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
@@ -37,7 +36,7 @@ export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) {
const adding = id == null;
const fromTrans = transaction != null;
const payees = getPayeesById(usePayees());
const { data: payees } = usePayeesById();
const globalDispatch = useDispatch();
// Create initial schedule if adding from transaction

View File

@@ -340,7 +340,7 @@ export function SchedulesTable({
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const [showCompleted, setShowCompleted] = useState(false);
const payees = usePayees();
const { data: payees } = usePayees();
const accounts = useAccounts();
const filteredSchedules = useMemo(() => {

View File

@@ -8,7 +8,7 @@ import {
useMultiuserEnabled,
} from '@desktop-client/components/ServerContext';
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('@desktop-client/hooks/useSyncServerStatus', () => ({
useSyncServerStatus: vi.fn(),
@@ -28,7 +28,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
const { container } = render(<AuthSettings />, { wrapper: TestProvider });
const { container } = render(<AuthSettings />, { wrapper: TestProviders });
expect(container.firstChild).toBeNull();
});
@@ -42,7 +42,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const startUsingButton = screen.getByRole('button', {
name: /start using openid/i,
@@ -59,7 +59,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('openid');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const disableButton = screen.getByRole('button', {
name: /disable openid/i,
@@ -82,7 +82,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const startUsingButton = screen.getByRole('button', {
name: /start using openid/i,
@@ -99,7 +99,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('openid');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const disableButton = screen.getByRole('button', {
name: /disable openid/i,
@@ -116,7 +116,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(true);
vi.mocked(useLoginMethod).mockReturnValue('openid');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const warningText = screen.getByText(
/disabling openid will deactivate multi-user mode\./i,

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

@@ -33,7 +33,10 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
import { TestProvider } from '@desktop-client/redux/mock';
import { createTestQueryClient, TestProviders } from '@desktop-client/mocks';
import { payeeQueries } from '@desktop-client/payees';
const queryClient = createTestQueryClient();
vi.mock('loot-core/platform/client/fetch');
vi.mock('../../hooks/useFeatureFlag', () => ({
@@ -68,22 +71,7 @@ const payees: PayeeEntity[] = [
name: 'This guy on the side of the road',
},
];
vi.mock('../../hooks/usePayees', async importOriginal => {
const actual =
// oxlint-disable-next-line typescript/consistent-type-imports
await importOriginal<typeof import('../../hooks/usePayees')>();
return {
...actual,
usePayees: () => payees,
usePayeesById: () => {
const payeesById: Record<string, PayeeEntity> = {};
payees.forEach(payee => {
payeesById[payee.id] = payee;
});
return payeesById;
},
};
});
queryClient.setQueryData(payeeQueries.list().queryKey, payees);
const categoryGroups = generateCategoryGroups([
{
@@ -195,7 +183,7 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
// implementation properly uses the right latest state even if the
// hook dependencies haven't changed
return (
<TestProvider>
<TestProviders queryClient={queryClient}>
<AuthProvider>
<SpreadsheetProvider>
<SchedulesProvider>
@@ -226,7 +214,7 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
</SchedulesProvider>
</SpreadsheetProvider>
</AuthProvider>
</TestProvider>
</TestProviders>
);
}

View File

@@ -138,7 +138,7 @@ import type { SplitsExpandedContextValue } from '@desktop-client/hooks/useSplits
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { NotesTagFormatter } from '@desktop-client/notes/NotesTagFormatter';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { getPayeesById } from '@desktop-client/payees/payeesSlice';
import { getPayeesById } from '@desktop-client/payees';
import { useDispatch } from '@desktop-client/redux';
type TransactionHeaderProps = {

View File

@@ -42,7 +42,7 @@ function AccountDisplayId({ id, noneColor }) {
function PayeeDisplayId({ id, noneColor }) {
const { t } = useTranslation();
const payee = usePayee(id);
const { data: payee } = usePayee(id);
return (
<TextOneLine
style={payee == null ? { color: noneColor } : null}

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';
@@ -13,7 +13,7 @@ import {
addGenericErrorNotification,
addNotification,
} from './notifications/notificationsSlice';
import { reloadPayees } from './payees/payeesSlice';
import { payeeQueries } from './payees';
import { loadPrefs } from './prefs/prefsSlice';
import type { AppStore } from './redux/store';
import * as syncEvents from './sync-events';
@@ -70,11 +70,17 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
tables.includes('payees') ||
tables.includes('payee_mapping')
) {
promises.push(store.dispatch(reloadPayees()));
queryClient.invalidateQueries({
queryKey: payeeQueries.lists(),
});
}
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

@@ -36,7 +36,7 @@ export function useAccountPreviewTransactions({
}: UseAccountPreviewTransactionsProps): UseAccountPreviewTransactionsResult {
const accounts = useAccounts();
const accountsById = useMemo(() => groupById(accounts), [accounts]);
const payees = usePayees();
const { data: payees = [] } = usePayees();
const payeesById = useMemo(() => groupById(payees), [payees]);
const getPayeeByTransferAccount = useCallback(

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { payeeQueries } from '@desktop-client/payees';
export function useCommonPayees() {
return useQuery(payeeQueries.listCommon());
}

View File

@@ -44,7 +44,7 @@ export function DisplayPayeeProvider({
});
const accounts = useAccounts();
const payeesById = usePayeesById();
const { data: payeesById = {} } = usePayeesById();
const displayPayees = useMemo(() => {
return transactions.reduce(

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

@@ -0,0 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { payeeQueries } from '@desktop-client/payees/queries';
export function useOrphanedPayees() {
return useQuery(payeeQueries.listOrphaned());
}

View File

@@ -1,8 +1,11 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { usePayees } from './usePayees';
import { payeeQueries } from '@desktop-client/payees';
export function usePayee(id: string) {
const payees = usePayees();
return useMemo(() => payees.find(p => p.id === id), [id, payees]);
export function usePayee(id?: string | null) {
return useQuery({
...payeeQueries.list(),
select: payees => payees.find(p => p.id === id),
enabled: !!id,
});
}

View File

@@ -1,33 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/fetch';
import { payeeQueries } from '@desktop-client/payees';
type PayeeRuleCounts = Map<string, number>;
type UsePayeeRuleCountsResult = {
ruleCounts: PayeeRuleCounts;
isLoading: boolean;
refetch: () => Promise<void>;
};
export function usePayeeRuleCounts(): UsePayeeRuleCountsResult {
const [ruleCounts, setRuleCounts] = useState<PayeeRuleCounts>(new Map());
const [isLoading, setIsLoading] = useState(true);
const refetch = useCallback(async () => {
setIsLoading(true);
try {
const counts = await send('payees-get-rule-counts');
const countsMap = new Map(Object.entries(counts));
setRuleCounts(countsMap);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refetch();
}, [refetch]);
return { ruleCounts, isLoading, refetch };
export function usePayeeRuleCounts() {
return useQuery(payeeQueries.ruleCounts());
}

View File

@@ -1,45 +1,14 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useInitialMount } from './useInitialMount';
import {
getCommonPayees,
getPayees,
getPayeesById,
} from '@desktop-client/payees/payeesSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
export function useCommonPayees() {
const dispatch = useDispatch();
const isInitialMount = useInitialMount();
const isCommonPayeesDirty = useSelector(
state => state.payees.isCommonPayeesDirty,
);
useEffect(() => {
if (isInitialMount || isCommonPayeesDirty) {
dispatch(getCommonPayees());
}
}, [dispatch, isInitialMount, isCommonPayeesDirty]);
return useSelector(state => state.payees.commonPayees);
}
import { getPayeesById, payeeQueries } from '@desktop-client/payees';
export function usePayees() {
const dispatch = useDispatch();
const isInitialMount = useInitialMount();
const isPayeesDirty = useSelector(state => state.payees.isPayeesDirty);
useEffect(() => {
if (isInitialMount || isPayeesDirty) {
dispatch(getPayees());
}
}, [dispatch, isInitialMount, isPayeesDirty]);
return useSelector(state => state.payees.payees);
return useQuery(payeeQueries.list());
}
export function usePayeesById() {
const payees = usePayees();
return getPayeesById(payees);
return useQuery({
...payeeQueries.list(),
select: payees => getPayeesById(payees),
});
}

View File

@@ -23,15 +23,19 @@ import { App } from './components/App';
import { ServerProvider } from './components/ServerContext';
import * as modalsSlice from './modals/modalsSlice';
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,
@@ -39,7 +43,6 @@ const boundActions = bindActionCreators(
...budgetfilesSlice.actions,
...modalsSlice.actions,
...notificationsSlice.actions,
...payeesSlice.actions,
...prefsSlice.actions,
...transactionsSlice.actions,
...tagsSlice.actions,
@@ -82,21 +85,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

@@ -0,0 +1,49 @@
import React from 'react';
import type { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { configureAppStore } from './redux/store';
import type { AppStore } from './redux/store';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
let testQueryClient = createTestQueryClient();
export function createTestAppStore() {
return configureAppStore({
queryClient: testQueryClient,
});
}
export let testStore: AppStore = createTestAppStore();
export function resetTestProviders() {
testQueryClient = createTestQueryClient();
testStore = createTestAppStore();
}
export function TestProviders({
children,
queryClient,
store,
}: {
queryClient?: QueryClient;
store?: AppStore;
children: ReactNode;
}) {
return (
<QueryClientProvider client={queryClient ?? testQueryClient}>
<Provider store={store ?? testStore}>{children}</Provider>
</QueryClientProvider>
);
}

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

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

View File

@@ -0,0 +1,75 @@
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 { sendCatch } from 'loot-core/platform/client/fetch';
import type { send } from 'loot-core/platform/client/fetch';
import type { PayeeEntity } from 'loot-core/types/models';
import { payeeQueries } from './queries';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
const sendThrow: typeof send = async (name, args) => {
const { error, data } = await sendCatch(name, args);
if (error) {
throw error;
}
return data;
};
function invalidateQueries(queryClient: QueryClient, queryKey?: QueryKey) {
queryClient.invalidateQueries({
queryKey: queryKey ?? payeeQueries.lists(),
});
}
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error ? error.message : undefined,
},
}),
);
}
type CreatePayeePayload = {
name: PayeeEntity['name'];
};
export function useCreatePayeeMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ name }: CreatePayeePayload) => {
const id: PayeeEntity['id'] = await sendThrow('payee-create', {
name: name.trim(),
});
return id;
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error creating payee:', error);
dispatchErrorNotification(
dispatch,
t('There was an error creating the payee. Please try again.'),
error,
);
throw error;
},
});
}

View File

@@ -1,230 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import { t } from 'i18next';
import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/fetch';
import { groupById } from 'loot-core/shared/util';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import { getAccountsById } from '@desktop-client/accounts/accountsSlice';
import { resetApp } from '@desktop-client/app/appSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
const sliceName = 'payees';
type PayeesState = {
commonPayees: PayeeEntity[];
isCommonPayeesLoading: boolean;
isCommonPayeesLoaded: boolean;
isCommonPayeesDirty: boolean;
payees: PayeeEntity[];
isPayeesLoading: boolean;
isPayeesLoaded: boolean;
isPayeesDirty: boolean;
};
const initialState: PayeesState = {
commonPayees: [],
isCommonPayeesLoading: false,
isCommonPayeesLoaded: false,
isCommonPayeesDirty: false,
payees: [],
isPayeesLoading: false,
isPayeesLoaded: false,
isPayeesDirty: false,
};
const payeesSlice = createSlice({
name: sliceName,
initialState,
reducers: {
markPayeesDirty(state) {
_markPayeesDirty(state);
},
},
extraReducers: builder => {
builder.addCase(resetApp, () => initialState);
builder.addCase(createPayee.fulfilled, _markPayeesDirty);
builder.addCase(reloadCommonPayees.fulfilled, (state, action) => {
_loadCommonPayees(state, action.payload);
});
builder.addCase(reloadCommonPayees.rejected, state => {
state.isCommonPayeesLoading = false;
});
builder.addCase(reloadCommonPayees.pending, state => {
state.isCommonPayeesLoading = true;
});
builder.addCase(getCommonPayees.fulfilled, (state, action) => {
_loadCommonPayees(state, action.payload);
});
builder.addCase(getCommonPayees.rejected, state => {
state.isCommonPayeesLoading = false;
});
builder.addCase(getCommonPayees.pending, state => {
state.isCommonPayeesLoading = true;
});
builder.addCase(reloadPayees.fulfilled, (state, action) => {
_loadPayees(state, action.payload);
});
builder.addCase(reloadPayees.rejected, state => {
state.isPayeesLoading = false;
});
builder.addCase(reloadPayees.pending, state => {
state.isPayeesLoading = true;
});
builder.addCase(getPayees.fulfilled, (state, action) => {
_loadPayees(state, action.payload);
});
builder.addCase(getPayees.rejected, state => {
state.isPayeesLoading = false;
});
builder.addCase(getPayees.pending, state => {
state.isPayeesLoading = true;
});
},
});
type CreatePayeePayload = {
name: PayeeEntity['name'];
};
function translatePayees(
payees: PayeeEntity[] | null | undefined,
): PayeeEntity[] | null | undefined {
return (
payees?.map(payee =>
payee.name === 'Starting Balance'
? { ...payee, name: t('Starting Balance') }
: payee,
) ?? payees
);
}
export const createPayee = createAppAsyncThunk(
`${sliceName}/createPayee`,
async ({ name }: CreatePayeePayload) => {
const id: PayeeEntity['id'] = await send('payee-create', {
name: name.trim(),
});
return id;
},
);
export const getCommonPayees = createAppAsyncThunk(
`${sliceName}/getCommonPayees`,
async () => {
const payees: PayeeEntity[] = await send('common-payees-get');
return translatePayees(payees) as PayeeEntity[];
},
{
condition: (_, { getState }) => {
const { payees } = getState();
return (
!payees.isCommonPayeesLoading &&
(payees.isCommonPayeesDirty || !payees.isCommonPayeesLoaded)
);
},
},
);
export const reloadCommonPayees = createAppAsyncThunk(
`${sliceName}/reloadCommonPayees`,
async () => {
const payees: PayeeEntity[] = await send('common-payees-get');
return translatePayees(payees) as PayeeEntity[];
},
);
export const getPayees = createAppAsyncThunk(
`${sliceName}/getPayees`,
async () => {
const payees: PayeeEntity[] = await send('payees-get');
return translatePayees(payees) as PayeeEntity[];
},
{
condition: (_, { getState }) => {
const { payees } = getState();
return (
!payees.isPayeesLoading &&
(payees.isPayeesDirty || !payees.isPayeesLoaded)
);
},
},
);
export const reloadPayees = createAppAsyncThunk(
`${sliceName}/reloadPayees`,
async () => {
const payees: PayeeEntity[] = await send('payees-get');
return translatePayees(payees) as PayeeEntity[];
},
);
export const getActivePayees = memoizeOne(
(payees: PayeeEntity[], accounts: AccountEntity[]) => {
const accountsById = getAccountsById(accounts);
return translatePayees(
payees.filter(payee => {
if (payee.transfer_acct) {
const account = accountsById[payee.transfer_acct];
return account != null && !account.closed;
}
return true;
}) as PayeeEntity[],
);
},
);
export const getPayeesById = memoizeOne(
(payees: PayeeEntity[] | null | undefined) =>
groupById(translatePayees(payees)),
);
export const { name, reducer, getInitialState } = payeesSlice;
export const actions = {
...payeesSlice.actions,
createPayee,
getCommonPayees,
reloadCommonPayees,
getPayees,
reloadPayees,
};
export const { markPayeesDirty } = payeesSlice.actions;
function _loadCommonPayees(
state: PayeesState,
commonPayees: PayeesState['commonPayees'],
) {
state.commonPayees = translatePayees(commonPayees) as PayeeEntity[];
state.isCommonPayeesLoading = false;
state.isCommonPayeesLoaded = true;
state.isCommonPayeesDirty = false;
}
function _loadPayees(state: PayeesState, payees: PayeesState['payees']) {
state.payees = translatePayees(payees) as PayeeEntity[];
state.isPayeesLoading = false;
state.isPayeesLoaded = true;
state.isPayeesDirty = false;
}
function _markPayeesDirty(state: PayeesState) {
state.isCommonPayeesDirty = true;
state.isPayeesDirty = true;
}

View File

@@ -0,0 +1,86 @@
import { queryOptions } from '@tanstack/react-query';
import { t } from 'i18next';
import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/fetch';
import { groupById } from 'loot-core/shared/util';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import { getAccountsById } from '@desktop-client/accounts/accountsSlice';
export const payeeQueries = {
all: () => ['payees'],
lists: () => [...payeeQueries.all(), 'lists'],
list: () =>
queryOptions<PayeeEntity[]>({
queryKey: [...payeeQueries.lists()],
queryFn: async () => {
const payees: PayeeEntity[] = (await send('payees-get')) ?? [];
return translatePayees(payees);
},
placeholderData: [],
// Manually invalidated when payees change via sync events
staleTime: Infinity,
}),
listCommon: () =>
queryOptions<PayeeEntity[]>({
queryKey: [...payeeQueries.lists(), 'common'],
queryFn: async () => {
const payees: PayeeEntity[] = (await send('common-payees-get')) ?? [];
return translatePayees(payees);
},
placeholderData: [],
// Manually invalidated when payees change via sync events
staleTime: Infinity,
}),
listOrphaned: () =>
queryOptions<Pick<PayeeEntity, 'id'>[]>({
queryKey: [...payeeQueries.lists(), 'orphaned'],
queryFn: async () => {
const payees: Pick<PayeeEntity, 'id'>[] =
(await send('payees-get-orphaned')) ?? [];
return payees;
},
placeholderData: [],
// Manually invalidated when payees change via sync events
staleTime: Infinity,
}),
ruleCounts: () =>
queryOptions<Map<PayeeEntity['id'], number>>({
queryKey: [...payeeQueries.lists(), 'ruleCounts'],
queryFn: async () => {
const counts = await send('payees-get-rule-counts');
return new Map(Object.entries(counts));
},
placeholderData: new Map(),
}),
};
export const getActivePayees = memoizeOne(
(payees: PayeeEntity[], accounts: AccountEntity[]) => {
const accountsById = getAccountsById(accounts);
return translatePayees(
payees.filter(payee => {
if (payee.transfer_acct) {
const account = accountsById[payee.transfer_acct];
return account != null && !account.closed;
}
return true;
}) as PayeeEntity[],
);
},
);
export const getPayeesById = memoizeOne(
(payees: PayeeEntity[] | null | undefined) =>
groupById(translatePayees(payees || [])),
);
function translatePayees(payees: PayeeEntity[]): PayeeEntity[] {
return payees.map(payee =>
payee.name === 'Starting Balance'
? { ...payee, name: t('Starting Balance') }
: payee,
);
}

View File

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

View File

@@ -1,75 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import type { store as realStore } 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';
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 function resetMockStore() {
mockStore = configureStore({
reducer: appReducer,
});
}
export function TestProvider({ children }: { children: ReactNode }) {
return <Provider store={mockStore}>{children}</Provider>;
}

View File

@@ -4,6 +4,7 @@ import {
createListenerMiddleware,
isRejected,
} from '@reduxjs/toolkit';
import type { QueryClient } from '@tanstack/react-query';
import {
name as accountsSliceName,
@@ -26,10 +27,6 @@ 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,
@@ -53,7 +50,6 @@ const rootReducer = combineReducers({
[budgetfilesSliceName]: budgetfilesSliceReducer,
[modalsSliceName]: modalsSliceReducer,
[notificationsSliceName]: notificationsSliceReducer,
[payeesSliceName]: payeesSliceReducer,
[prefsSliceName]: prefsSliceReducer,
[transactionsSliceName]: transactionsSliceReducer,
[tagsSliceName]: tagsSliceReducer,
@@ -77,16 +73,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

@@ -1,6 +1,6 @@
import '@testing-library/jest-dom';
import { resetTestProviders } from './mocks';
import { installPolyfills } from './polyfills';
import { resetMockStore } from './redux/mock';
installPolyfills();
@@ -22,7 +22,7 @@ vi.mock('react-virtualized-auto-sizer', () => {
global.Date.now = () => 123456789;
global.__resetWorld = () => {
resetMockStore();
resetTestProviders();
};
process.on('unhandledRejection', reason => {

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 {
@@ -14,7 +14,7 @@ import {
import { pushModal } from './modals/modalsSlice';
import { addNotification } from './notifications/notificationsSlice';
import type { Notification } from './notifications/notificationsSlice';
import { reloadPayees } from './payees/payeesSlice';
import { payeeQueries } from './payees';
import { loadPrefs } from './prefs/prefsSlice';
import type { AppStore } from './redux/store';
import { signOut } from './users/usersSlice';
@@ -82,11 +82,15 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
tables.includes('payees') ||
tables.includes('payee_mapping')
) {
store.dispatch(reloadPayees());
queryClient.invalidateQueries({
queryKey: payeeQueries.lists(),
});
}
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;

View File

@@ -56,7 +56,8 @@
"**/lib-dist/*",
"**/test-results/*",
"**/playwright-report/*",
"**/service-worker/*"
"**/service-worker/*",
"packages/docs/**/*"
],
"ts-node": {
"compilerOptions": {

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Migrate account state management from Redux to React Query with updated hooks.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Migrate payee and account state management from Redux to React Query for improved performance.