From 9175d909005f212ad3ebfc3e7607a57e4e6461e9 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 18 Dec 2024 22:50:24 -0800 Subject: [PATCH 01/26] Migrate to accountSlice --- .../src/client/accounts/accountSlice.ts | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 packages/loot-core/src/client/accounts/accountSlice.ts diff --git a/packages/loot-core/src/client/accounts/accountSlice.ts b/packages/loot-core/src/client/accounts/accountSlice.ts new file mode 100644 index 00000000000..cf51c13ef86 --- /dev/null +++ b/packages/loot-core/src/client/accounts/accountSlice.ts @@ -0,0 +1,331 @@ +import { + createAsyncThunk, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit'; + +import { + type AccountEntity, + type TransactionEntity, +} from '../../types/models'; + +import { send } from '../../platform/client/fetch'; +import { addNotification, getAccounts, getPayees } from '../actions'; +import * as constants from '../constants'; +import { type AppDispatch, type RootState } from '../store'; + +const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState; + dispatch: AppDispatch; +}>(); + +const initialState: AccountState = { + failedAccounts: {}, + accountsSyncing: [], +}; + +type SetAccountsSyncingPayload = { + ids: Array; +}; + +type MarkAccountFailedPayload = { + id: AccountEntity['id']; + errorType: string; + errorCode: string; +}; + +type MarkAccountSuccessPayload = { + id: AccountEntity['id']; +}; + +type AccountState = { + failedAccounts: { [key: AccountEntity['id']]: { type: string; code: string } }; + accountsSyncing: Array; +}; + +const accountSlice = createSlice({ + name: 'account', + initialState, + reducers: { + setAccountsSyncing( + state, + action: PayloadAction, + ) { + const payload = action.payload; + state.accountsSyncing = payload.ids; + }, + markAccountFailed(state, action: PayloadAction) { + const payload = action.payload; + state.failedAccounts[payload.id] = { + type: payload.errorType, + code: payload.errorCode, + }; + }, + markAccountSuccess( + state, + action: PayloadAction, + ) { + const payload = action.payload; + delete state.failedAccounts[payload.id]; + }, + }, +}); + +const { setAccountsSyncing, markAccountFailed, markAccountSuccess } = + accountSlice.actions; + +type UnlinkAccountArgs = { + id: string; +}; + +export const unlinkAccount = createAppAsyncThunk( + 'accounts/unlinkAccount', + async ({ id }: UnlinkAccountArgs, thunkApi) => { + await send('account-unlink', { id }); + thunkApi.dispatch(markAccountSuccess({ id })); + thunkApi.dispatch(getAccounts()); + }, +); + +type LinkAccountArgs = { + requisitionId: string; + account: unknown; + upgradingId?: string; + offBudget?: boolean; +}; + +export const linkAccount = createAppAsyncThunk( + 'accounts/linkAccount', + async ( + { requisitionId, account, upgradingId, offBudget }: LinkAccountArgs, + thunkApi, + ) => { + await send('gocardless-accounts-link', { + requisitionId, + account, + upgradingId, + offBudget, + }); + await thunkApi.dispatch(getPayees()); + await thunkApi.dispatch(getAccounts()); + }, +); + +type LinkAccountSimpleFinArgs = { + externalAccount: unknown; + upgradingId?: string; + offBudget?: boolean; +}; + +export const linkAccountSimpleFin = createAppAsyncThunk( + 'accounts/linkAccountSimpleFin', + async ( + { externalAccount, upgradingId, offBudget }: LinkAccountSimpleFinArgs, + thunkApi, + ) => { + await send('simplefin-accounts-link', { + externalAccount, + upgradingId, + offBudget, + }); + await thunkApi.dispatch(getPayees()); + await thunkApi.dispatch(getAccounts()); + }, +); + +function handleSyncResponse( + accountId: string, + res: { + errors: Array<{ + type: string; + category: string; + code: string; + message: string; + internal?: string; + }>; + newTransactions: TransactionEntity[]; + matchedTransactions: TransactionEntity[]; + updatedAccounts: AccountEntity[]; + }, + dispatch: AppDispatch, + resNewTransactions: TransactionEntity[], + resMatchedTransactions: TransactionEntity[], + resUpdatedAccounts: AccountEntity[], +) { + 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 (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 (error.type === 'SyncError') { + dispatch( + addNotification({ + type: 'error', + message: error.message, + }), + ); + } else { + dispatch( + addNotification({ + type: 'error', + message: error.message, + internal: error.internal, + }), + ); + } + }); + + resNewTransactions.push(...newTransactions); + resMatchedTransactions.push(...matchedTransactions); + resUpdatedAccounts.push(...updatedAccounts); + + return newTransactions.length > 0 || matchedTransactions.length > 0; +} + +type SyncAccountsArgs = { + id?: string; +}; + +export const syncAccounts = createAppAsyncThunk( + 'accounts/syncAccounts', + async ({ id }: SyncAccountsArgs, thunkApi) => { + // Disallow two parallel sync operations + if (thunkApi.getState().account.accountsSyncing.length > 0) { + return false; + } + + const batchSync = !id; + + // Build an array of IDs for accounts to sync.. if no `id` provided + // then we assume that all accounts should be synced + let accountIdsToSync = !batchSync + ? [id] + : thunkApi + .getState() + .queries.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); + + thunkApi.dispatch(setAccountsSyncing({ ids: accountIdsToSync })); + + const accountsData: AccountEntity[] = await send('accounts-get'); + const simpleFinAccounts = accountsData.filter( + a => a.account_sync_source === 'simpleFin', + ); + + let isSyncSuccess = false; + const newTransactions: TransactionEntity[] = []; + const matchedTransactions: TransactionEntity[] = []; + const updatedAccounts: AccountEntity[] = []; + + if (batchSync && 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, + thunkApi.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, + thunkApi.dispatch, + newTransactions, + matchedTransactions, + updatedAccounts, + ); + + if (success) isSyncSuccess = true; + + // Dispatch the ids for the accounts that are yet to be synced + thunkApi.dispatch( + setAccountsSyncing({ ids: accountIdsToSync.slice(idx + 1) }), + ); + } + + // Set new transactions + thunkApi.dispatch({ + type: constants.SET_NEW_TRANSACTIONS, + newTransactions, + matchedTransactions, + updatedAccounts, + }); + + // Reset the sync state back to empty (fallback in case something breaks + // in the logic above) + thunkApi.dispatch(setAccountsSyncing({ ids: [] })); + return isSyncSuccess; + }, +); + +type MoveAccountArgs = { + id: string; + targetId: string; +}; + +export const moveAccount = createAppAsyncThunk( + 'accounts/moveAccount', + async ({ id, targetId }: MoveAccountArgs, thunkApi) => { + await send('account-move', { id, targetId }); + thunkApi.dispatch(getAccounts()); + thunkApi.dispatch(getPayees()); + }, +); + +export const { name, reducer, getInitialState } = accountSlice; +export const actions = { + ...accountSlice.actions, + linkAccount, + unlinkAccount, + syncAccounts, + linkAccountSimpleFin, + moveAccount, +}; From 3e41cbb716cd2c6bb5632eb02073b1afca3e4854 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 18 Dec 2024 23:01:04 -0800 Subject: [PATCH 02/26] Fix lint and typecheck errors --- packages/loot-core/src/client/accounts/accountSlice.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/loot-core/src/client/accounts/accountSlice.ts b/packages/loot-core/src/client/accounts/accountSlice.ts index cf51c13ef86..8b009168864 100644 --- a/packages/loot-core/src/client/accounts/accountSlice.ts +++ b/packages/loot-core/src/client/accounts/accountSlice.ts @@ -4,12 +4,8 @@ import { type PayloadAction, } from '@reduxjs/toolkit'; -import { - type AccountEntity, - type TransactionEntity, -} from '../../types/models'; - import { send } from '../../platform/client/fetch'; +import { type AccountEntity, type TransactionEntity } from '../../types/models'; import { addNotification, getAccounts, getPayees } from '../actions'; import * as constants from '../constants'; import { type AppDispatch, type RootState } from '../store'; @@ -39,7 +35,9 @@ type MarkAccountSuccessPayload = { }; type AccountState = { - failedAccounts: { [key: AccountEntity['id']]: { type: string; code: string } }; + failedAccounts: { + [key: AccountEntity['id']]: { type: string; code: string }; + }; accountsSyncing: Array; }; From 828219b0b3a6d40808f8ff6f4c157ea6489842fe Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 18 Dec 2024 23:07:38 -0800 Subject: [PATCH 03/26] Update types --- .../src/client/accounts/accountSlice.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/loot-core/src/client/accounts/accountSlice.ts b/packages/loot-core/src/client/accounts/accountSlice.ts index 8b009168864..049ebe1ac60 100644 --- a/packages/loot-core/src/client/accounts/accountSlice.ts +++ b/packages/loot-core/src/client/accounts/accountSlice.ts @@ -20,21 +20,21 @@ const initialState: AccountState = { accountsSyncing: [], }; -type SetAccountsSyncingPayload = { +type SetAccountsSyncingAction = PayloadAction<{ ids: Array; -}; +}>; -type MarkAccountFailedPayload = { +type MarkAccountFailedAction = PayloadAction<{ id: AccountEntity['id']; errorType: string; errorCode: string; -}; +}>; -type MarkAccountSuccessPayload = { +type MarkAccountSuccessAction = PayloadAction<{ id: AccountEntity['id']; -}; +}>; -type AccountState = { +interface AccountState { failedAccounts: { [key: AccountEntity['id']]: { type: string; code: string }; }; @@ -47,12 +47,12 @@ const accountSlice = createSlice({ reducers: { setAccountsSyncing( state, - action: PayloadAction, + action: SetAccountsSyncingAction, ) { const payload = action.payload; state.accountsSyncing = payload.ids; }, - markAccountFailed(state, action: PayloadAction) { + markAccountFailed(state, action: MarkAccountFailedAction) { const payload = action.payload; state.failedAccounts[payload.id] = { type: payload.errorType, @@ -61,7 +61,7 @@ const accountSlice = createSlice({ }, markAccountSuccess( state, - action: PayloadAction, + action: MarkAccountSuccessAction, ) { const payload = action.payload; delete state.failedAccounts[payload.id]; From 0e1291d644dcf8fbda343a047fee868f334e9f98 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 18 Dec 2024 23:28:04 -0800 Subject: [PATCH 04/26] Fix lint --- .../loot-core/src/client/accounts/accountSlice.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/loot-core/src/client/accounts/accountSlice.ts b/packages/loot-core/src/client/accounts/accountSlice.ts index 049ebe1ac60..3b7b8fa33af 100644 --- a/packages/loot-core/src/client/accounts/accountSlice.ts +++ b/packages/loot-core/src/client/accounts/accountSlice.ts @@ -34,7 +34,7 @@ type MarkAccountSuccessAction = PayloadAction<{ id: AccountEntity['id']; }>; -interface AccountState { +type AccountState = { failedAccounts: { [key: AccountEntity['id']]: { type: string; code: string }; }; @@ -45,10 +45,7 @@ const accountSlice = createSlice({ name: 'account', initialState, reducers: { - setAccountsSyncing( - state, - action: SetAccountsSyncingAction, - ) { + setAccountsSyncing(state, action: SetAccountsSyncingAction) { const payload = action.payload; state.accountsSyncing = payload.ids; }, @@ -59,10 +56,7 @@ const accountSlice = createSlice({ code: payload.errorCode, }; }, - markAccountSuccess( - state, - action: MarkAccountSuccessAction, - ) { + markAccountSuccess(state, action: MarkAccountSuccessAction) { const payload = action.payload; delete state.failedAccounts[payload.id]; }, From daefe9fd473502428769b9e262639d74451b9aaf Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 19 Dec 2024 12:18:38 -0800 Subject: [PATCH 05/26] Fix types --- .../src/client/accounts/accountSlice.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/loot-core/src/client/accounts/accountSlice.ts b/packages/loot-core/src/client/accounts/accountSlice.ts index 3b7b8fa33af..6c477e54e36 100644 --- a/packages/loot-core/src/client/accounts/accountSlice.ts +++ b/packages/loot-core/src/client/accounts/accountSlice.ts @@ -135,14 +135,14 @@ function handleSyncResponse( message: string; internal?: string; }>; - newTransactions: TransactionEntity[]; - matchedTransactions: TransactionEntity[]; - updatedAccounts: AccountEntity[]; + newTransactions: Array; + matchedTransactions: Array; + updatedAccounts: Array; }, dispatch: AppDispatch, - resNewTransactions: TransactionEntity[], - resMatchedTransactions: TransactionEntity[], - resUpdatedAccounts: AccountEntity[], + resNewTransactions: Array, + resMatchedTransactions: Array, + resUpdatedAccounts: Array, ) { const { errors, newTransactions, matchedTransactions, updatedAccounts } = res; @@ -229,9 +229,9 @@ export const syncAccounts = createAppAsyncThunk( ); let isSyncSuccess = false; - const newTransactions: TransactionEntity[] = []; - const matchedTransactions: TransactionEntity[] = []; - const updatedAccounts: AccountEntity[] = []; + const newTransactions: Array = []; + const matchedTransactions: Array = []; + const updatedAccounts: Array = []; if (batchSync && simpleFinAccounts.length > 0) { console.log('Using SimpleFin batch sync'); From db3ab7670270314aecb9e1c43cf621a71b3788b5 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 19 Dec 2024 14:38:58 -0800 Subject: [PATCH 06/26] Cleanup --- .../loot-core/src/client/accounts/accountSlice.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/loot-core/src/client/accounts/accountSlice.ts b/packages/loot-core/src/client/accounts/accountSlice.ts index 6c477e54e36..d12374b6ac3 100644 --- a/packages/loot-core/src/client/accounts/accountSlice.ts +++ b/packages/loot-core/src/client/accounts/accountSlice.ts @@ -126,7 +126,7 @@ export const linkAccountSimpleFin = createAppAsyncThunk( ); function handleSyncResponse( - accountId: string, + accountId: AccountEntity['id'], res: { errors: Array<{ type: string; @@ -199,7 +199,8 @@ export const syncAccounts = createAppAsyncThunk( 'accounts/syncAccounts', async ({ id }: SyncAccountsArgs, thunkApi) => { // Disallow two parallel sync operations - if (thunkApi.getState().account.accountsSyncing.length > 0) { + const accountState = thunkApi.getState().account; + if (accountState.accountsSyncing.length > 0) { return false; } @@ -207,11 +208,11 @@ export const syncAccounts = createAppAsyncThunk( // Build an array of IDs for accounts to sync.. if no `id` provided // then we assume that all accounts should be synced + const queriesState = thunkApi.getState().queries; let accountIdsToSync = !batchSync ? [id] - : thunkApi - .getState() - .queries.accounts.filter( + : queriesState.accounts + .filter( ({ bank, closed, tombstone }) => !!bank && !closed && !tombstone, ) .sort((a, b) => From 8f84d459926e79d091cfd00690ef77cdc0efd7e8 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 20 Dec 2024 08:46:35 -0800 Subject: [PATCH 07/26] Rename file --- .../src/client/accounts/accountSlice.ts | 324 ------------------ 1 file changed, 324 deletions(-) delete mode 100644 packages/loot-core/src/client/accounts/accountSlice.ts diff --git a/packages/loot-core/src/client/accounts/accountSlice.ts b/packages/loot-core/src/client/accounts/accountSlice.ts deleted file mode 100644 index d12374b6ac3..00000000000 --- a/packages/loot-core/src/client/accounts/accountSlice.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { - createAsyncThunk, - createSlice, - type PayloadAction, -} from '@reduxjs/toolkit'; - -import { send } from '../../platform/client/fetch'; -import { type AccountEntity, type TransactionEntity } from '../../types/models'; -import { addNotification, getAccounts, getPayees } from '../actions'; -import * as constants from '../constants'; -import { type AppDispatch, type RootState } from '../store'; - -const createAppAsyncThunk = createAsyncThunk.withTypes<{ - state: RootState; - dispatch: AppDispatch; -}>(); - -const initialState: AccountState = { - failedAccounts: {}, - accountsSyncing: [], -}; - -type SetAccountsSyncingAction = PayloadAction<{ - ids: Array; -}>; - -type MarkAccountFailedAction = PayloadAction<{ - id: AccountEntity['id']; - errorType: string; - errorCode: string; -}>; - -type MarkAccountSuccessAction = PayloadAction<{ - id: AccountEntity['id']; -}>; - -type AccountState = { - failedAccounts: { - [key: AccountEntity['id']]: { type: string; code: string }; - }; - accountsSyncing: Array; -}; - -const accountSlice = createSlice({ - name: 'account', - initialState, - reducers: { - setAccountsSyncing(state, action: SetAccountsSyncingAction) { - const payload = action.payload; - state.accountsSyncing = payload.ids; - }, - markAccountFailed(state, action: MarkAccountFailedAction) { - const payload = action.payload; - state.failedAccounts[payload.id] = { - type: payload.errorType, - code: payload.errorCode, - }; - }, - markAccountSuccess(state, action: MarkAccountSuccessAction) { - const payload = action.payload; - delete state.failedAccounts[payload.id]; - }, - }, -}); - -const { setAccountsSyncing, markAccountFailed, markAccountSuccess } = - accountSlice.actions; - -type UnlinkAccountArgs = { - id: string; -}; - -export const unlinkAccount = createAppAsyncThunk( - 'accounts/unlinkAccount', - async ({ id }: UnlinkAccountArgs, thunkApi) => { - await send('account-unlink', { id }); - thunkApi.dispatch(markAccountSuccess({ id })); - thunkApi.dispatch(getAccounts()); - }, -); - -type LinkAccountArgs = { - requisitionId: string; - account: unknown; - upgradingId?: string; - offBudget?: boolean; -}; - -export const linkAccount = createAppAsyncThunk( - 'accounts/linkAccount', - async ( - { requisitionId, account, upgradingId, offBudget }: LinkAccountArgs, - thunkApi, - ) => { - await send('gocardless-accounts-link', { - requisitionId, - account, - upgradingId, - offBudget, - }); - await thunkApi.dispatch(getPayees()); - await thunkApi.dispatch(getAccounts()); - }, -); - -type LinkAccountSimpleFinArgs = { - externalAccount: unknown; - upgradingId?: string; - offBudget?: boolean; -}; - -export const linkAccountSimpleFin = createAppAsyncThunk( - 'accounts/linkAccountSimpleFin', - async ( - { externalAccount, upgradingId, offBudget }: LinkAccountSimpleFinArgs, - thunkApi, - ) => { - await send('simplefin-accounts-link', { - externalAccount, - upgradingId, - offBudget, - }); - await thunkApi.dispatch(getPayees()); - await thunkApi.dispatch(getAccounts()); - }, -); - -function handleSyncResponse( - accountId: AccountEntity['id'], - res: { - errors: Array<{ - type: string; - category: string; - code: string; - message: string; - internal?: string; - }>; - newTransactions: Array; - matchedTransactions: Array; - updatedAccounts: Array; - }, - dispatch: AppDispatch, - resNewTransactions: Array, - resMatchedTransactions: Array, - resUpdatedAccounts: Array, -) { - const { errors, newTransactions, matchedTransactions, updatedAccounts } = res; - - // Mark the account as failed or succeeded (depending on sync output) - const [error] = errors; - if (error) { - // We only want to mark the account as having problem if it - // was a real syncing error. - if (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 (error.type === 'SyncError') { - dispatch( - addNotification({ - type: 'error', - message: error.message, - }), - ); - } else { - dispatch( - addNotification({ - type: 'error', - message: error.message, - internal: error.internal, - }), - ); - } - }); - - resNewTransactions.push(...newTransactions); - resMatchedTransactions.push(...matchedTransactions); - resUpdatedAccounts.push(...updatedAccounts); - - return newTransactions.length > 0 || matchedTransactions.length > 0; -} - -type SyncAccountsArgs = { - id?: string; -}; - -export const syncAccounts = createAppAsyncThunk( - 'accounts/syncAccounts', - async ({ id }: SyncAccountsArgs, thunkApi) => { - // Disallow two parallel sync operations - const accountState = thunkApi.getState().account; - if (accountState.accountsSyncing.length > 0) { - return false; - } - - const batchSync = !id; - - // Build an array of IDs for accounts to sync.. if no `id` provided - // then we assume that all accounts should be synced - const queriesState = thunkApi.getState().queries; - let accountIdsToSync = !batchSync - ? [id] - : queriesState.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); - - thunkApi.dispatch(setAccountsSyncing({ ids: accountIdsToSync })); - - const accountsData: AccountEntity[] = await send('accounts-get'); - const simpleFinAccounts = accountsData.filter( - a => a.account_sync_source === 'simpleFin', - ); - - let isSyncSuccess = false; - const newTransactions: Array = []; - const matchedTransactions: Array = []; - const updatedAccounts: Array = []; - - if (batchSync && 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, - thunkApi.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, - thunkApi.dispatch, - newTransactions, - matchedTransactions, - updatedAccounts, - ); - - if (success) isSyncSuccess = true; - - // Dispatch the ids for the accounts that are yet to be synced - thunkApi.dispatch( - setAccountsSyncing({ ids: accountIdsToSync.slice(idx + 1) }), - ); - } - - // Set new transactions - thunkApi.dispatch({ - type: constants.SET_NEW_TRANSACTIONS, - newTransactions, - matchedTransactions, - updatedAccounts, - }); - - // Reset the sync state back to empty (fallback in case something breaks - // in the logic above) - thunkApi.dispatch(setAccountsSyncing({ ids: [] })); - return isSyncSuccess; - }, -); - -type MoveAccountArgs = { - id: string; - targetId: string; -}; - -export const moveAccount = createAppAsyncThunk( - 'accounts/moveAccount', - async ({ id, targetId }: MoveAccountArgs, thunkApi) => { - await send('account-move', { id, targetId }); - thunkApi.dispatch(getAccounts()); - thunkApi.dispatch(getPayees()); - }, -); - -export const { name, reducer, getInitialState } = accountSlice; -export const actions = { - ...accountSlice.actions, - linkAccount, - unlinkAccount, - syncAccounts, - linkAccountSimpleFin, - moveAccount, -}; From aee1fe66e26ff75aec3c01c6a9c3d7557654337a Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 20 Dec 2024 08:54:34 -0800 Subject: [PATCH 08/26] Rename state --- packages/desktop-client/src/components/BankSyncStatus.tsx | 2 +- packages/desktop-client/src/components/accounts/Account.tsx | 2 +- .../src/components/mobile/accounts/AccountTransactions.tsx | 4 +++- .../src/components/mobile/accounts/Accounts.tsx | 4 +++- packages/desktop-client/src/components/sidebar/Accounts.tsx | 4 +++- packages/desktop-client/src/hooks/useFailedAccounts.ts | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/desktop-client/src/components/BankSyncStatus.tsx b/packages/desktop-client/src/components/BankSyncStatus.tsx index 843b62d3e3f..26c6908f5f2 100644 --- a/packages/desktop-client/src/components/BankSyncStatus.tsx +++ b/packages/desktop-client/src/components/BankSyncStatus.tsx @@ -10,7 +10,7 @@ import { Text } from './common/Text'; import { View } from './common/View'; export function BankSyncStatus() { - const accountsSyncing = useSelector(state => state.account.accountsSyncing); + const accountsSyncing = useSelector(state => state.accounts.accountsSyncing); const accountsSyncingCount = accountsSyncing.length; const count = accountsSyncingCount; diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index 08b6b397f12..ea618d05ece 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -1959,7 +1959,7 @@ export function Account() { `show-extra-balances-${params.id || 'all-accounts'}`, ); const modalShowing = useSelector(state => state.modals.modalStack.length > 0); - const accountsSyncing = useSelector(state => state.account.accountsSyncing); + const accountsSyncing = useSelector(state => state.accounts.accountsSyncing); const filterConditions = location?.state?.filterConditions || []; const savedFiters = useFilters(); diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index afd617fc4b3..78d09d9a966 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -99,7 +99,9 @@ export function AccountTransactions({ function AccountHeader({ account }: { readonly account: AccountEntity }) { const failedAccounts = useFailedAccounts(); - const syncingAccountIds = useSelector(state => state.account.accountsSyncing); + const syncingAccountIds = useSelector( + state => state.accounts.accountsSyncing, + ); const pending = useMemo( () => syncingAccountIds.includes(account.id), [syncingAccountIds, account.id], diff --git a/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx b/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx index 16cb2f958dd..2e25d125fe8 100644 --- a/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx @@ -226,7 +226,9 @@ function AccountList({ }: AccountListProps) { const { t } = useTranslation(); const failedAccounts = useFailedAccounts(); - const syncingAccountIds = useSelector(state => state.account.accountsSyncing); + const syncingAccountIds = useSelector( + state => state.accounts.accountsSyncing, + ); const onBudgetAccounts = accounts.filter(account => account.offbudget === 0); const offBudgetAccounts = accounts.filter(account => account.offbudget === 1); diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index fb8eadfaea3..ed91a312a05 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -31,7 +31,9 @@ export function Accounts() { const offbudgetAccounts = useOffBudgetAccounts(); const onBudgetAccounts = useOnBudgetAccounts(); const closedAccounts = useClosedAccounts(); - const syncingAccountIds = useSelector(state => state.account.accountsSyncing); + const syncingAccountIds = useSelector( + state => state.accounts.accountsSyncing, + ); const getAccountPath = (account: AccountEntity) => `/accounts/${account.id}`; diff --git a/packages/desktop-client/src/hooks/useFailedAccounts.ts b/packages/desktop-client/src/hooks/useFailedAccounts.ts index 86b20b948fc..20bd2b2a50d 100644 --- a/packages/desktop-client/src/hooks/useFailedAccounts.ts +++ b/packages/desktop-client/src/hooks/useFailedAccounts.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useSelector } from '../redux'; export function useFailedAccounts() { - const failedAccounts = useSelector(state => state.account.failedAccounts); + const failedAccounts = useSelector(state => state.accounts.failedAccounts); return useMemo( () => new Map(Object.entries(failedAccounts)), [failedAccounts], From accffd5aa9d2af8da9d61fd48730e561225e4cb6 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 20 Dec 2024 15:57:40 -0800 Subject: [PATCH 09/26] Cleanup --- packages/loot-core/src/client/store/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/loot-core/src/client/store/index.ts b/packages/loot-core/src/client/store/index.ts index d124832bd0f..7a24dbc038a 100644 --- a/packages/loot-core/src/client/store/index.ts +++ b/packages/loot-core/src/client/store/index.ts @@ -3,6 +3,7 @@ import { configureStore, createListenerMiddleware, isRejected, + createAsyncThunk, } from '@reduxjs/toolkit'; import { @@ -85,3 +86,8 @@ export type AppStore = typeof store; export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; export type GetRootState = typeof store.getState; + +export const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState; + dispatch: AppDispatch; +}>(); From 67ae7382430724d8bd9799fce2b7c8cbdb85ad7b Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 8 Jan 2025 13:40:39 -0800 Subject: [PATCH 10/26] Fix typecheck error --- packages/desktop-client/src/components/accounts/Account.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index ea618d05ece..bdb8758e196 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -63,6 +63,7 @@ import { type RuleConditionEntity, type TransactionEntity, type TransactionFilterEntity, + type PayeeEntity, } from 'loot-core/src/types/models'; import { useAccountPreviewTransactions } from '../../hooks/useAccountPreviewTransactions'; From ce0f9e049fec52069e101228fffa008f051e2206 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 9 Jan 2025 01:00:58 -0800 Subject: [PATCH 11/26] Move createAppAsyncThunk --- packages/loot-core/src/client/store/index.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/loot-core/src/client/store/index.ts b/packages/loot-core/src/client/store/index.ts index 7a24dbc038a..d124832bd0f 100644 --- a/packages/loot-core/src/client/store/index.ts +++ b/packages/loot-core/src/client/store/index.ts @@ -3,7 +3,6 @@ import { configureStore, createListenerMiddleware, isRejected, - createAsyncThunk, } from '@reduxjs/toolkit'; import { @@ -86,8 +85,3 @@ export type AppStore = typeof store; export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; export type GetRootState = typeof store.getState; - -export const createAppAsyncThunk = createAsyncThunk.withTypes<{ - state: RootState; - dispatch: AppDispatch; -}>(); From 86459206b44ffa3b25451271fddc85d3003219d6 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 19 Dec 2024 13:56:50 -0800 Subject: [PATCH 12/26] Queries slice --- packages/desktop-client/src/components/BankSyncStatus.tsx | 2 +- packages/desktop-client/src/components/accounts/Account.tsx | 3 +-- .../src/components/mobile/accounts/AccountTransactions.tsx | 4 +--- .../src/components/mobile/accounts/Accounts.tsx | 4 +--- packages/desktop-client/src/components/sidebar/Accounts.tsx | 4 +--- packages/desktop-client/src/hooks/useFailedAccounts.ts | 2 +- 6 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/desktop-client/src/components/BankSyncStatus.tsx b/packages/desktop-client/src/components/BankSyncStatus.tsx index 26c6908f5f2..843b62d3e3f 100644 --- a/packages/desktop-client/src/components/BankSyncStatus.tsx +++ b/packages/desktop-client/src/components/BankSyncStatus.tsx @@ -10,7 +10,7 @@ import { Text } from './common/Text'; import { View } from './common/View'; export function BankSyncStatus() { - const accountsSyncing = useSelector(state => state.accounts.accountsSyncing); + const accountsSyncing = useSelector(state => state.account.accountsSyncing); const accountsSyncingCount = accountsSyncing.length; const count = accountsSyncingCount; diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index bdb8758e196..08b6b397f12 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -63,7 +63,6 @@ import { type RuleConditionEntity, type TransactionEntity, type TransactionFilterEntity, - type PayeeEntity, } from 'loot-core/src/types/models'; import { useAccountPreviewTransactions } from '../../hooks/useAccountPreviewTransactions'; @@ -1960,7 +1959,7 @@ export function Account() { `show-extra-balances-${params.id || 'all-accounts'}`, ); const modalShowing = useSelector(state => state.modals.modalStack.length > 0); - const accountsSyncing = useSelector(state => state.accounts.accountsSyncing); + const accountsSyncing = useSelector(state => state.account.accountsSyncing); const filterConditions = location?.state?.filterConditions || []; const savedFiters = useFilters(); diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index 78d09d9a966..afd617fc4b3 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -99,9 +99,7 @@ export function AccountTransactions({ function AccountHeader({ account }: { readonly account: AccountEntity }) { const failedAccounts = useFailedAccounts(); - const syncingAccountIds = useSelector( - state => state.accounts.accountsSyncing, - ); + const syncingAccountIds = useSelector(state => state.account.accountsSyncing); const pending = useMemo( () => syncingAccountIds.includes(account.id), [syncingAccountIds, account.id], diff --git a/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx b/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx index 2e25d125fe8..16cb2f958dd 100644 --- a/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx @@ -226,9 +226,7 @@ function AccountList({ }: AccountListProps) { const { t } = useTranslation(); const failedAccounts = useFailedAccounts(); - const syncingAccountIds = useSelector( - state => state.accounts.accountsSyncing, - ); + const syncingAccountIds = useSelector(state => state.account.accountsSyncing); const onBudgetAccounts = accounts.filter(account => account.offbudget === 0); const offBudgetAccounts = accounts.filter(account => account.offbudget === 1); diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index ed91a312a05..fb8eadfaea3 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -31,9 +31,7 @@ export function Accounts() { const offbudgetAccounts = useOffBudgetAccounts(); const onBudgetAccounts = useOnBudgetAccounts(); const closedAccounts = useClosedAccounts(); - const syncingAccountIds = useSelector( - state => state.accounts.accountsSyncing, - ); + const syncingAccountIds = useSelector(state => state.account.accountsSyncing); const getAccountPath = (account: AccountEntity) => `/accounts/${account.id}`; diff --git a/packages/desktop-client/src/hooks/useFailedAccounts.ts b/packages/desktop-client/src/hooks/useFailedAccounts.ts index 20bd2b2a50d..86b20b948fc 100644 --- a/packages/desktop-client/src/hooks/useFailedAccounts.ts +++ b/packages/desktop-client/src/hooks/useFailedAccounts.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useSelector } from '../redux'; export function useFailedAccounts() { - const failedAccounts = useSelector(state => state.accounts.failedAccounts); + const failedAccounts = useSelector(state => state.account.failedAccounts); return useMemo( () => new Map(Object.entries(failedAccounts)), [failedAccounts], From 92c85ee567dce741f66cc4d75b4c3df19e628c3e Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 20 Dec 2024 08:41:40 -0800 Subject: [PATCH 13/26] App slice --- packages/desktop-client/playwright.config.js | 8 +-- .../src/browser-preload.browser.js | 9 +-- .../desktop-client/src/components/App.tsx | 2 +- .../src/components/FatalError.tsx | 2 +- .../src/components/HelpMenu.tsx | 27 +++++++- .../src/components/UpdateNotification.tsx | 4 +- .../src/components/accounts/Account.tsx | 4 +- .../src/components/manager/ConfigServer.tsx | 4 +- .../src/components/manager/ManagementApp.tsx | 5 +- .../ImportTransactionsModal.jsx | 2 +- .../manager/ConfirmChangeDocumentDir.tsx | 4 +- .../modals/manager/FilesSettingsModal.tsx | 2 +- .../modals/manager/ImportActualModal.tsx | 2 +- .../modals/manager/ImportYNAB4Modal.tsx | 2 +- .../modals/manager/ImportYNAB5Modal.tsx | 2 +- .../src/components/reports/Overview.tsx | 4 +- .../src/components/settings/Export.tsx | 2 +- packages/desktop-client/src/global-events.ts | 5 +- packages/desktop-client/src/gocardless.ts | 2 +- packages/desktop-client/src/index.tsx | 41 ++++++++---- packages/desktop-client/src/util/versions.ts | 4 +- packages/desktop-electron/index.ts | 10 ++- packages/desktop-electron/preload.ts | 12 +++- packages/loot-core/src/client/actions/app.ts | 66 ------------------- .../loot-core/src/client/actions/budgets.ts | 2 +- .../loot-core/src/client/actions/index.ts | 1 - packages/loot-core/src/client/app/appSlice.ts | 62 +++++++++++++++++ packages/loot-core/src/client/reducers/app.ts | 22 ------- .../loot-core/src/client/reducers/index.ts | 2 - packages/loot-core/src/client/store/index.ts | 9 ++- packages/loot-core/src/client/store/mock.ts | 5 ++ packages/loot-core/typings/window.d.ts | 58 +++++++++------- 32 files changed, 215 insertions(+), 171 deletions(-) delete mode 100644 packages/loot-core/src/client/actions/app.ts create mode 100644 packages/loot-core/src/client/app/appSlice.ts delete mode 100644 packages/loot-core/src/client/reducers/app.ts diff --git a/packages/desktop-client/playwright.config.js b/packages/desktop-client/playwright.config.js index 170172dc70b..cd075de8005 100644 --- a/packages/desktop-client/playwright.config.js +++ b/packages/desktop-client/playwright.config.js @@ -26,7 +26,7 @@ expect.extend({ : locator.locator('[data-theme]'); // Check lightmode - await locator.evaluate(() => window.Actual.setTheme('auto')); + await locator.evaluate(() => global.Actual.setTheme('auto')); await expect(dataThemeLocator).toHaveAttribute('data-theme', 'auto'); const lightmode = await expect(locator).toHaveScreenshot(config); @@ -35,7 +35,7 @@ expect.extend({ } // Switch to darkmode and check - await locator.evaluate(() => window.Actual.setTheme('dark')); + await locator.evaluate(() => global.Actual.setTheme('dark')); await expect(dataThemeLocator).toHaveAttribute('data-theme', 'dark'); const darkmode = await expect(locator).toHaveScreenshot(config); @@ -45,7 +45,7 @@ expect.extend({ } // Switch to midnight theme and check - await locator.evaluate(() => window.Actual.setTheme('midnight')); + await locator.evaluate(() => global.Actual.setTheme('midnight')); await expect(dataThemeLocator).toHaveAttribute('data-theme', 'midnight'); const midnightMode = await expect(locator).toHaveScreenshot(config); @@ -55,7 +55,7 @@ expect.extend({ } // Switch back to lightmode - await locator.evaluate(() => window.Actual.setTheme('auto')); + await locator.evaluate(() => global.Actual.setTheme('auto')); return { message: () => 'pass', pass: true, diff --git a/packages/desktop-client/src/browser-preload.browser.js b/packages/desktop-client/src/browser-preload.browser.js index 69834cca51c..346b0d396ad 100644 --- a/packages/desktop-client/src/browser-preload.browser.js +++ b/packages/desktop-client/src/browser-preload.browser.js @@ -2,6 +2,7 @@ import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-th // eslint-disable-next-line import/no-unresolved import { registerSW } from 'virtual:pwa-register'; +import { send } from 'loot-core/platform/client/fetch'; import * as Platform from 'loot-core/src/client/platform'; import packageJson from '../package.json'; @@ -121,10 +122,10 @@ global.Actual = { reader.readAsArrayBuffer(file); reader.onload = async function (ev) { const filepath = `/uploads/${filename}`; - - window.__actionsForMenu - .uploadFile(filename, ev.target.result) - .then(() => resolve([filepath])); + send('upload-file-web', { + filename, + contents: ev.target.result, + }).then(() => resolve([filepath])); }; reader.onerror = function () { alert('Error reading file'); diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index fafac793317..3a20b4bde91 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -16,10 +16,10 @@ import { closeBudget, loadBudget, loadGlobalPrefs, - setAppState, signOut, sync, } from 'loot-core/client/actions'; +import { setAppState } from 'loot-core/client/app/appSlice'; import { SpreadsheetProvider } from 'loot-core/client/SpreadsheetProvider'; import * as Platform from 'loot-core/src/client/platform'; import { diff --git a/packages/desktop-client/src/components/FatalError.tsx b/packages/desktop-client/src/components/FatalError.tsx index 682ba97aa24..d2d902bdd11 100644 --- a/packages/desktop-client/src/components/FatalError.tsx +++ b/packages/desktop-client/src/components/FatalError.tsx @@ -217,7 +217,7 @@ export function FatalError({ error }: FatalErrorProps) { )} - diff --git a/packages/desktop-client/src/components/HelpMenu.tsx b/packages/desktop-client/src/components/HelpMenu.tsx index 11c292add30..f3086402c11 100644 --- a/packages/desktop-client/src/components/HelpMenu.tsx +++ b/packages/desktop-client/src/components/HelpMenu.tsx @@ -5,7 +5,6 @@ import { useLocation } from 'react-router-dom'; import { useToggle } from 'usehooks-ts'; -import { openDocsForCurrentPage } from 'loot-core/client/actions'; import { pushModal } from 'loot-core/client/actions/modals'; import { useFeatureFlag } from '../hooks/useFeatureFlag'; @@ -17,6 +16,30 @@ import { Menu } from './common/Menu'; import { Popover } from './common/Popover'; import { SpaceBetween } from './common/SpaceBetween'; +const getPageDocs = (page: string) => { + switch (page) { + case '/budget': + return 'https://actualbudget.org/docs/getting-started/envelope-budgeting'; + case '/reports': + return 'https://actualbudget.org/docs/reports/'; + case '/schedules': + return 'https://actualbudget.org/docs/schedules'; + case '/payees': + return 'https://actualbudget.org/docs/transactions/payees'; + case '/rules': + return 'https://actualbudget.org/docs/budgeting/rules'; + case '/settings': + return 'https://actualbudget.org/docs/settings'; + default: + // All pages under /accounts, plus any missing pages + return 'https://actualbudget.org/docs'; + } +}; + +function openDocsForCurrentPage() { + global.Actual.openURLInBrowser(getPageDocs(window.location.pathname)); +} + type HelpMenuItem = 'docs' | 'keyboard-shortcuts' | 'goal-templates'; type HelpButtonProps = { @@ -58,7 +81,7 @@ export const HelpMenu = () => { const handleItemSelect = (item: HelpMenuItem) => { switch (item) { case 'docs': - dispatch(openDocsForCurrentPage()); + openDocsForCurrentPage(); break; case 'keyboard-shortcuts': dispatch(pushModal('keyboard-shortcuts')); diff --git a/packages/desktop-client/src/components/UpdateNotification.tsx b/packages/desktop-client/src/components/UpdateNotification.tsx index 469b76b98b8..0c5bb5e21af 100644 --- a/packages/desktop-client/src/components/UpdateNotification.tsx +++ b/packages/desktop-client/src/components/UpdateNotification.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { setAppState, updateApp } from 'loot-core/client/actions'; +import { setAppState, updateApp } from 'loot-core/client/app/appSlice'; import { SvgClose } from '../icons/v1'; import { useSelector, useDispatch } from '../redux'; @@ -69,7 +69,7 @@ export function UpdateNotification() { textDecoration: 'underline', }} onClick={() => - window.Actual?.openURLInBrowser( + global.Actual.openURLInBrowser( 'https://actualbudget.org/docs/releases', ) } diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index 08b6b397f12..bfc0a08c65c 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -633,7 +633,7 @@ class AccountInternal extends PureComponent< const account = this.props.accounts.find(acct => acct.id === accountId); if (account) { - const res = await window.Actual?.openFileDialog({ + const res = await global.Actual.openFileDialog({ filters: [ { name: t('Financial Files'), @@ -668,7 +668,7 @@ class AccountInternal extends PureComponent< accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-'); const filename = `${normalizedName || 'transactions'}.csv`; - window.Actual?.saveFile( + global.Actual.saveFile( exportedTransactions, filename, t('Export Transactions'), diff --git a/packages/desktop-client/src/components/manager/ConfigServer.tsx b/packages/desktop-client/src/components/manager/ConfigServer.tsx index a8020f083dc..c3bbb5a4b6e 100644 --- a/packages/desktop-client/src/components/manager/ConfigServer.tsx +++ b/packages/desktop-client/src/components/manager/ConfigServer.tsx @@ -35,7 +35,7 @@ export function ConfigServer() { const [error, setError] = useState(null); const restartElectronServer = useCallback(() => { - globalThis.window.Actual.restartElectronServer(); + global.Actual.restartElectronServer(); setError(null); }, []); @@ -88,7 +88,7 @@ export function ConfigServer() { } async function onSelectSelfSignedCertificate() { - const selfSignedCertificateLocation = await window.Actual?.openFileDialog({ + const selfSignedCertificateLocation = await global.Actual.openFileDialog({ properties: ['openFile'], filters: [ { diff --git a/packages/desktop-client/src/components/manager/ManagementApp.tsx b/packages/desktop-client/src/components/manager/ManagementApp.tsx index 872bf98363a..3ff6f8ce49e 100644 --- a/packages/desktop-client/src/components/manager/ManagementApp.tsx +++ b/packages/desktop-client/src/components/manager/ManagementApp.tsx @@ -1,7 +1,8 @@ import React, { useEffect } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; -import { loggedIn, setAppState } from 'loot-core/client/actions'; +import { loggedIn } from 'loot-core/client/actions'; +import { setAppState } from 'loot-core/client/app/appSlice'; import { ProtectedRoute } from '../../auth/ProtectedRoute'; import { Permissions } from '../../auth/types'; @@ -51,7 +52,7 @@ function Version() { }, }} > - {`App: v${window.Actual?.ACTUAL_VERSION} | Server: ${version}`} + {`App: v${global.Actual.ACTUAL_VERSION} | Server: ${version}`} ); } diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx index dceb8312640..6f8daa1c04b 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx +++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx @@ -471,7 +471,7 @@ export function ImportTransactionsModal({ options }) { } async function onNewFile() { - const res = await window.Actual?.openFileDialog({ + const res = await global.Actual.openFileDialog({ filters: [ { name: 'Financial Files', diff --git a/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx b/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx index adf3e5dfe35..b1702dfdcf4 100644 --- a/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx +++ b/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx @@ -51,7 +51,7 @@ export function ConfirmChangeDocumentDirModal({ const dispatch = useDispatch(); const restartElectronServer = useCallback(() => { - globalThis.window.Actual?.restartElectronServer(); + global.Actual.restartElectronServer(); }, []); const [_documentDir, setDocumentDirPref] = useGlobalPref( @@ -64,7 +64,7 @@ export function ConfirmChangeDocumentDirModal({ setLoading(true); try { if (moveFiles) { - await globalThis.window.Actual?.moveBudgetDirectory( + await global.Actual.moveBudgetDirectory( currentBudgetDirectory, newDirectory, ); diff --git a/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx index ee3c1107def..476c329602b 100644 --- a/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx +++ b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx @@ -19,7 +19,7 @@ function FileLocationSettings() { const dispatch = useDispatch(); async function onChooseDocumentDir() { - const chosenDirectory = await window.Actual?.openFileDialog({ + const chosenDirectory = await global.Actual.openFileDialog({ properties: ['openDirectory'], }); diff --git a/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx b/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx index 995154b13e7..733f970620b 100644 --- a/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx +++ b/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx @@ -38,7 +38,7 @@ export function ImportActualModal() { const [importing, setImporting] = useState(false); async function onImport() { - const res = await window.Actual?.openFileDialog({ + const res = await global.Actual.openFileDialog({ properties: ['openFile'], filters: [{ name: 'actual', extensions: ['zip', 'blob'] }], }); diff --git a/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx b/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx index 49a80721bf9..e6f11670660 100644 --- a/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx +++ b/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx @@ -30,7 +30,7 @@ export function ImportYNAB4Modal() { const [importing, setImporting] = useState(false); async function onImport() { - const res = await window.Actual?.openFileDialog({ + const res = await global.Actual.openFileDialog({ properties: ['openFile'], filters: [{ name: 'ynab', extensions: ['zip'] }], }); diff --git a/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx b/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx index 8945278e80a..773d0181a27 100644 --- a/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx +++ b/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx @@ -33,7 +33,7 @@ export function ImportYNAB5Modal() { const [importing, setImporting] = useState(false); async function onImport() { - const res = await window.Actual?.openFileDialog({ + const res = await global.Actual.openFileDialog({ properties: ['openFile'], filters: [{ name: 'ynab', extensions: ['json'] }], }); diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index 36023b89b6a..558f20aed66 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -211,14 +211,14 @@ export function Overview() { }), } satisfies ExportImportDashboard; - window.Actual?.saveFile( + global.Actual.saveFile( JSON.stringify(data, null, 2), 'dashboard.json', 'Export Dashboard', ); }; const onImport = async () => { - const openFileDialog = window.Actual?.openFileDialog; + const openFileDialog = global.Actual.openFileDialog; if (!openFileDialog) { dispatch( diff --git a/packages/desktop-client/src/components/settings/Export.tsx b/packages/desktop-client/src/components/settings/Export.tsx index 393752a7b2d..2b43a22f386 100644 --- a/packages/desktop-client/src/components/settings/Export.tsx +++ b/packages/desktop-client/src/components/settings/Export.tsx @@ -33,7 +33,7 @@ export function ExportBudget() { return; } - window.Actual?.saveFile( + global.Actual.saveFile( response.data, `${format(new Date(), 'yyyy-MM-dd')}-${budgetName}.zip`, t('Export budget'), diff --git a/packages/desktop-client/src/global-events.ts b/packages/desktop-client/src/global-events.ts index 3bd98bd6c4b..b0fb7b3d66f 100644 --- a/packages/desktop-client/src/global-events.ts +++ b/packages/desktop-client/src/global-events.ts @@ -6,10 +6,9 @@ import { closeModal, loadPrefs, pushModal, - reloadApp, replaceModal, - setAppState, } from 'loot-core/client/actions'; +import { setAppState } from 'loot-core/client/app/appSlice'; import { getAccounts, getCategories, @@ -163,6 +162,6 @@ export function handleGlobalEvents(store: AppStore) { }); listen('api-fetch-redirected', () => { - store.dispatch(reloadApp()); + global.Actual.reload(); }); } diff --git a/packages/desktop-client/src/gocardless.ts b/packages/desktop-client/src/gocardless.ts index dc4d48b6332..b76c3dea751 100644 --- a/packages/desktop-client/src/gocardless.ts +++ b/packages/desktop-client/src/gocardless.ts @@ -25,7 +25,7 @@ function _authorize( if ('error' in resp) return resp; const { link, requisitionId } = resp; - window.Actual?.openURLInBrowser(link); + global.Actual.openURLInBrowser(link); return send('gocardless-poll-web-token', { upgradingAccountId, diff --git a/packages/desktop-client/src/index.tsx b/packages/desktop-client/src/index.tsx index 9ed73b8f0bf..a06cb0ecf82 100644 --- a/packages/desktop-client/src/index.tsx +++ b/packages/desktop-client/src/index.tsx @@ -15,6 +15,7 @@ import { createRoot } from 'react-dom/client'; import * as accountsSlice from 'loot-core/src/client/accounts/accountsSlice'; import * as actions from 'loot-core/src/client/actions'; +import * as appSlice from 'loot-core/src/client/app/appSlice'; import * as queriesSlice from 'loot-core/src/client/queries/queriesSlice'; import { runQuery } from 'loot-core/src/client/query-helpers'; import { store } from 'loot-core/src/client/store'; @@ -35,6 +36,7 @@ const boundActions = bindActionCreators( { ...actions, ...accountsSlice.actions, + ...appSlice.actions, ...queriesSlice.actions, }, store.dispatch, @@ -43,19 +45,8 @@ const boundActions = bindActionCreators( // Listen for global events from the server or main process handleGlobalEvents(store); -declare global { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Window { - __actionsForMenu: typeof boundActions & { - undo: typeof undo; - redo: typeof redo; - inputFocused: typeof inputFocused; - }; - - $send: typeof send; - $query: typeof runQuery; - $q: typeof q; - } +async function appFocused() { + await send('app-focused'); } function inputFocused() { @@ -67,7 +58,13 @@ function inputFocused() { } // Expose this to the main process to menu items can access it -window.__actionsForMenu = { ...boundActions, undo, redo, inputFocused }; +window.__actionsForMenu = { + ...boundActions, + undo, + redo, + appFocused, + inputFocused, +}; // Expose send for fun! window.$send = send; @@ -85,3 +82,19 @@ root.render( , ); + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Window { + __actionsForMenu: typeof boundActions & { + undo: typeof undo; + redo: typeof redo; + appFocused: typeof appFocused; + inputFocused: typeof inputFocused; + }; + + $send: typeof send; + $query: typeof runQuery; + $q: typeof q; + } +} diff --git a/packages/desktop-client/src/util/versions.ts b/packages/desktop-client/src/util/versions.ts index 09427e15f2c..0c9d8e5d30e 100644 --- a/packages/desktop-client/src/util/versions.ts +++ b/packages/desktop-client/src/util/versions.ts @@ -30,7 +30,7 @@ export async function getLatestVersion(): Promise { const json = await response.json(); const tags = json .map(t => t.name) - .concat([`v${window.Actual?.ACTUAL_VERSION}`]); + .concat([`v${global.Actual.ACTUAL_VERSION}`]); tags.sort(cmpSemanticVersion); return tags[tags.length - 1]; @@ -41,7 +41,7 @@ export async function getLatestVersion(): Promise { } export async function getIsOutdated(latestVersion: string): Promise { - const clientVersion = window.Actual?.ACTUAL_VERSION; + const clientVersion = global.Actual.ACTUAL_VERSION; if (latestVersion === 'unknown') { return Promise.resolve(false); } diff --git a/packages/desktop-electron/index.ts b/packages/desktop-electron/index.ts index 3a910e05573..885d6f71dfc 100644 --- a/packages/desktop-electron/index.ts +++ b/packages/desktop-electron/index.ts @@ -229,7 +229,7 @@ async function createWindow() { const url = clientWin.webContents.getURL(); if (url.includes('app://') || url.includes('localhost:')) { clientWin.webContents.executeJavaScript( - 'window.__actionsForMenu.focused()', + 'window.__actionsForMenu.appFocused()', ); } } @@ -435,7 +435,7 @@ ipcMain.handle( export type SaveFileDialogPayload = { title: SaveDialogOptions['title']; defaultPath?: SaveDialogOptions['defaultPath']; - fileContents: string | NodeJS.ArrayBufferView; + fileContents: string | Buffer; }; ipcMain.handle( @@ -448,7 +448,11 @@ ipcMain.handle( return new Promise((resolve, reject) => { if (fileLocation) { - fs.writeFile(fileLocation.filePath, fileContents, error => { + const contents = + typeof fileContents === 'string' + ? fileContents + : new Uint8Array(fileContents.buffer); + fs.writeFile(fileLocation.filePath, contents, error => { return reject(error); }); } diff --git a/packages/desktop-electron/preload.ts b/packages/desktop-electron/preload.ts index 85b282dcb12..63f491133da 100644 --- a/packages/desktop-electron/preload.ts +++ b/packages/desktop-electron/preload.ts @@ -71,7 +71,7 @@ contextBridge.exposeInMainWorld('Actual', { isUpdateReadyForDownload: () => false, waitForUpdateReadyForDownload: () => new Promise(() => {}), - getServerSocket: () => { + getServerSocket: async () => { return null; }, @@ -89,4 +89,12 @@ contextBridge.exposeInMainWorld('Actual', { newDirectory, ); }, -}); + + reload: async () => { + throw new Error('Reload not implemented in electron app'); + }, + + applyAppUpdate: async () => { + throw new Error('applyAppUpdate not implemented in electron app'); + }, +} satisfies typeof global.Actual); diff --git a/packages/loot-core/src/client/actions/app.ts b/packages/loot-core/src/client/actions/app.ts deleted file mode 100644 index bc199564039..00000000000 --- a/packages/loot-core/src/client/actions/app.ts +++ /dev/null @@ -1,66 +0,0 @@ -// @ts-strict-ignore -import { send } from '../../platform/client/fetch'; -import * as constants from '../constants'; -import type { AppState, SetAppStateAction } from '../state-types/app'; -import { type AppDispatch } from '../store'; - -export function setAppState(state: Partial): SetAppStateAction { - return { - type: constants.SET_APP_STATE, - state, - }; -} - -export function updateApp() { - return async (dispatch: AppDispatch) => { - await global.Actual.applyAppUpdate(); - dispatch(setAppState({ updateInfo: null })); - }; -} - -// This is only used in the fake web version where everything runs in -// the browser. It's a way to send a file to the backend to be -// imported into the virtual filesystem. -export function uploadFile(filename: string, contents: ArrayBuffer) { - return () => { - return send('upload-file-web', { filename, contents }); - }; -} - -export function focused() { - return () => { - return send('app-focused'); - }; -} - -export function reloadApp() { - return () => { - global.Actual.reload(); - }; -} - -const getPageDocs = (page: string) => { - switch (page) { - case '/budget': - return 'https://actualbudget.org/docs/getting-started/envelope-budgeting'; - case '/reports': - return 'https://actualbudget.org/docs/reports/'; - case '/schedules': - return 'https://actualbudget.org/docs/schedules'; - case '/payees': - return 'https://actualbudget.org/docs/transactions/payees'; - case '/rules': - return 'https://actualbudget.org/docs/budgeting/rules'; - case '/settings': - return 'https://actualbudget.org/docs/settings'; - default: - // All pages under /accounts, plus any missing pages - return 'https://actualbudget.org/docs'; - } -}; - -export function openDocsForCurrentPage() { - return () => { - global.Actual.openURLInBrowser(getPageDocs(window.location.pathname)); - }; -} diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts index cac82a0f76d..f95bf260e04 100644 --- a/packages/loot-core/src/client/actions/budgets.ts +++ b/packages/loot-core/src/client/actions/budgets.ts @@ -4,10 +4,10 @@ import { t } from 'i18next'; import { send } from '../../platform/client/fetch'; import { getDownloadError, getSyncError } from '../../shared/errors'; import type { Handlers } from '../../types/handlers'; +import { setAppState } from '../app/appSlice'; import * as constants from '../constants'; import { type AppDispatch, type GetRootState } from '../store'; -import { setAppState } from './app'; import { closeModal, pushModal } from './modals'; import { loadPrefs, loadGlobalPrefs } from './prefs'; diff --git a/packages/loot-core/src/client/actions/index.ts b/packages/loot-core/src/client/actions/index.ts index 51ebdfd4ea1..5662edf4e84 100644 --- a/packages/loot-core/src/client/actions/index.ts +++ b/packages/loot-core/src/client/actions/index.ts @@ -2,7 +2,6 @@ export * from './modals'; export * from './notifications'; export * from './prefs'; export * from './budgets'; -export * from './app'; export * from './backups'; export * from './sync'; export * from './user'; diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts new file mode 100644 index 00000000000..79cdb8d3c0f --- /dev/null +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -0,0 +1,62 @@ +import { + createAsyncThunk, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit'; + +import { type AppDispatch, type RootState } from '../store'; + +const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState; + dispatch: AppDispatch; +}>(); + +type AppState = { + loadingText: string | null; + updateInfo: { + version: string; + releaseDate: string; + releaseNotes: string; + } | null; + showUpdateNotification: boolean; + managerHasInitialized: boolean; +}; + +const initialState: AppState = { + loadingText: null, + updateInfo: null, + showUpdateNotification: true, + managerHasInitialized: false, +}; + +export const updateApp = createAppAsyncThunk( + 'app/updateApp', + async (_, thunkApi) => { + await global.Actual.applyAppUpdate(); + thunkApi.dispatch(setAppState({ updateInfo: null })); + }, +); + +type SetAppStateAction = PayloadAction>; + +const accountSlice = createSlice({ + name: 'app', + initialState, + reducers: { + setAppState(state, action: SetAppStateAction) { + return { + ...state, + ...action.payload, + }; + }, + }, +}); + +export const { name, reducer, getInitialState } = accountSlice; + +export const actions = { + ...accountSlice.actions, + updateApp, +}; + +export const { setAppState } = actions; diff --git a/packages/loot-core/src/client/reducers/app.ts b/packages/loot-core/src/client/reducers/app.ts deleted file mode 100644 index db1161f12c1..00000000000 --- a/packages/loot-core/src/client/reducers/app.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as constants from '../constants'; -import type { Action } from '../state-types'; -import type { AppState } from '../state-types/app'; - -export const initialState: AppState = { - loadingText: null, - updateInfo: null, - showUpdateNotification: true, - managerHasInitialized: false, -}; - -export function update(state = initialState, action: Action): AppState { - switch (action.type) { - case constants.SET_APP_STATE: - return { - ...state, - ...action.state, - }; - default: - } - return state; -} diff --git a/packages/loot-core/src/client/reducers/index.ts b/packages/loot-core/src/client/reducers/index.ts index d87b61a7534..3cdfc844992 100644 --- a/packages/loot-core/src/client/reducers/index.ts +++ b/packages/loot-core/src/client/reducers/index.ts @@ -1,4 +1,3 @@ -import { update as app } from './app'; import { update as budgets } from './budgets'; import { update as modals } from './modals'; import { update as notifications } from './notifications'; @@ -6,7 +5,6 @@ import { update as prefs } from './prefs'; import { update as user } from './user'; export const reducers = { - app, prefs, modals, notifications, diff --git a/packages/loot-core/src/client/store/index.ts b/packages/loot-core/src/client/store/index.ts index d124832bd0f..8ad3d8f4663 100644 --- a/packages/loot-core/src/client/store/index.ts +++ b/packages/loot-core/src/client/store/index.ts @@ -11,6 +11,11 @@ import { getInitialState as getInitialAccountsState, } from '../accounts/accountsSlice'; import { addNotification } from '../actions'; +import { + name as appSliceName, + reducer as appSliceReducer, + getInitialState as getInitialAppState, +} from '../app/appSlice'; import * as constants from '../constants'; import { name as queriesSliceName, @@ -18,7 +23,6 @@ import { getInitialState as getInitialQueriesState, } from '../queries/queriesSlice'; import { reducers } from '../reducers'; -import { initialState as initialAppState } from '../reducers/app'; import { initialState as initialBudgetsState } from '../reducers/budgets'; import { initialState as initialModalsState } from '../reducers/modals'; import { initialState as initialNotificationsState } from '../reducers/notifications'; @@ -28,6 +32,7 @@ import { initialState as initialUserState } from '../reducers/user'; const appReducer = combineReducers({ ...reducers, [accountsSliceName]: accountsSliceReducer, + [appSliceName]: appSliceReducer, [queriesSliceName]: queriesSliceReducer, }); const rootReducer: typeof appReducer = (state, action) => { @@ -47,7 +52,7 @@ const rootReducer: typeof appReducer = (state, action) => { synced: initialPrefsState.synced, }, app: { - ...initialAppState, + ...getInitialAppState(), managerHasInitialized: state?.app?.managerHasInitialized || false, loadingText: state?.app?.loadingText || null, }, diff --git a/packages/loot-core/src/client/store/mock.ts b/packages/loot-core/src/client/store/mock.ts index 4d604573a1c..c2342bea301 100644 --- a/packages/loot-core/src/client/store/mock.ts +++ b/packages/loot-core/src/client/store/mock.ts @@ -4,6 +4,10 @@ import { name as accountsSliceName, reducer as accountsSliceReducer, } from '../accounts/accountsSlice'; +import { + name as appSliceName, + reducer as appSliceReducer, +} from '../app/appSlice'; import { name as queriesSliceName, reducer as queriesSliceReducer, @@ -15,6 +19,7 @@ import { type store as realStore } from './index'; const appReducer = combineReducers({ ...reducers, [accountsSliceName]: accountsSliceReducer, + [appSliceName]: appSliceReducer, [queriesSliceName]: queriesSliceReducer, }); diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts index 3a63353e776..e9afb1fa7fe 100644 --- a/packages/loot-core/typings/window.d.ts +++ b/packages/loot-core/typings/window.d.ts @@ -1,29 +1,43 @@ export {}; +type Actual = { + IS_DEV: boolean; + ACTUAL_VERSION: string; + openURLInBrowser: (url: string) => void; + saveFile: ( + contents: string | Buffer, + filename: string, + dialogTitle?: string, + ) => Promise; + openFileDialog: (options) => Promise; + relaunch: () => void; + reload: (() => Promise) | undefined; + restartElectronServer: () => void; + moveBudgetDirectory: ( + currentBudgetDirectory: string, + newDirectory: string, + ) => Promise; + applyAppUpdate: () => Promise; + updateAppMenu: (budgetId: string) => void; + ipcConnect: (callback: (client) => void) => void; + getServerSocket: () => Promise; + setTheme: (theme: string) => void; + logToTerminal: (...args: unknown[]) => void; + onEventFromMain: ( + event: string, + listener: (...args: unknown[]) => void, + ) => void; + isUpdateReadyForDownload: () => boolean; + waitForUpdateReadyForDownload: () => Promise; +}; + declare global { interface Window { - Actual?: { - IS_FAKE_WEB: boolean; - ACTUAL_VERSION: string; - openURLInBrowser: (url: string) => void; - saveFile: ( - contents: string | Buffer, - filename: string, - dialogTitle: string, - ) => void; - openFileDialog: ( - opts: Parameters[0], - ) => Promise; - relaunch: () => void; - reload: (() => Promise) | undefined; - restartElectronServer: () => void; - startOAuthServer: () => Promise; - moveBudgetDirectory: ( - currentBudgetDirectory: string, - newDirectory: string, - ) => Promise; - }; - __navigate?: import('react-router').NavigateFunction; } + + // eslint-disable-next-line no-var + var Actual: Actual; + // eslint-disable-next-line no-var + var IS_TESTING: boolean; } From 40877bf446d090363ba9ce789d507d36f6e4e7ba Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 20 Dec 2024 08:43:11 -0800 Subject: [PATCH 14/26] Release notes --- upcoming-release-notes/4018.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 upcoming-release-notes/4018.md diff --git a/upcoming-release-notes/4018.md b/upcoming-release-notes/4018.md new file mode 100644 index 00000000000..8d105830628 --- /dev/null +++ b/upcoming-release-notes/4018.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Phase 2 - Redux Toolkit Migration - app slice From e8d7b7f7e55f440fd924c718c6c238d12b605e8e Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 20 Dec 2024 09:26:04 -0800 Subject: [PATCH 15/26] Remove app state type --- .../loot-core/src/client/reducers/modals.ts | 20 +++++++++++++++---- .../loot-core/src/client/state-types/app.d.ts | 19 ------------------ .../src/client/state-types/index.d.ts | 3 --- 3 files changed, 16 insertions(+), 26 deletions(-) delete mode 100644 packages/loot-core/src/client/state-types/app.d.ts diff --git a/packages/loot-core/src/client/reducers/modals.ts b/packages/loot-core/src/client/reducers/modals.ts index 89f882f49c2..bcf1db0e041 100644 --- a/packages/loot-core/src/client/reducers/modals.ts +++ b/packages/loot-core/src/client/reducers/modals.ts @@ -7,7 +7,18 @@ export const initialState: ModalsState = { isHidden: false, }; -export function update(state = initialState, action: Action): ModalsState { +type ModalsAction = + | Action + // Temporary until we migrate to redux toolkit. + | { + type: 'app/setAppState'; + payload: { loadingText: string | null }; + }; + +export function update( + state = initialState, + action: ModalsAction, +): ModalsState { switch (action.type) { case constants.PUSH_MODAL: // special case: don't show the keyboard shortcuts modal if there's already a modal open @@ -44,11 +55,12 @@ export function update(state = initialState, action: Action): ModalsState { ...state, modalStack: idx < 0 ? state.modalStack : state.modalStack.slice(0, idx), }; - case constants.SET_APP_STATE: - if ('loadingText' in action.state) { + // Temporary until we migrate to redux toolkit. + case 'app/setAppState': + if (action.payload.loadingText) { return { ...state, - isHidden: action.state.loadingText != null, + isHidden: action.payload.loadingText != null, }; } break; diff --git a/packages/loot-core/src/client/state-types/app.d.ts b/packages/loot-core/src/client/state-types/app.d.ts deleted file mode 100644 index c0923f568cb..00000000000 --- a/packages/loot-core/src/client/state-types/app.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type * as constants from '../constants'; - -export type AppState = { - loadingText: string | null; - updateInfo: { - version: string; - releaseDate: string; - releaseNotes: string; - } | null; - showUpdateNotification: boolean; - managerHasInitialized: boolean; -}; - -export type SetAppStateAction = { - type: typeof constants.SET_APP_STATE; - state: Partial; -}; - -export type AppActions = SetAppStateAction; diff --git a/packages/loot-core/src/client/state-types/index.d.ts b/packages/loot-core/src/client/state-types/index.d.ts index ead231f695b..a7665c59a83 100644 --- a/packages/loot-core/src/client/state-types/index.d.ts +++ b/packages/loot-core/src/client/state-types/index.d.ts @@ -1,6 +1,5 @@ import type * as constants from '../constants'; -import type { AppActions, AppState } from './app'; import type { BudgetsActions, BudgetsState } from './budgets'; import type { ModalsActions, ModalsState } from './modals'; import type { NotificationsActions, NotificationsState } from './notifications'; @@ -12,7 +11,6 @@ export type CloseBudgetAction = { }; export type Action = - | AppActions | BudgetsActions | ModalsActions | NotificationsActions @@ -21,7 +19,6 @@ export type Action = | CloseBudgetAction; export type State = { - app: AppState; budgets: BudgetsState; modals: ModalsState; notifications: NotificationsState; From 670534c051a136b3d3c42e15b87d1323b9409089 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 7 Jan 2025 14:31:28 -0800 Subject: [PATCH 16/26] [TS] Actual startOAuthServer function --- packages/loot-core/typings/window.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts index e9afb1fa7fe..afe10ea6224 100644 --- a/packages/loot-core/typings/window.d.ts +++ b/packages/loot-core/typings/window.d.ts @@ -29,6 +29,7 @@ type Actual = { ) => void; isUpdateReadyForDownload: () => boolean; waitForUpdateReadyForDownload: () => Promise; + startOAuthServer: () => Promise; }; declare global { From c19cb2aee62cd00dc14e824f83e194f62e60372e Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 7 Jan 2025 14:54:49 -0800 Subject: [PATCH 17/26] Rename slice --- packages/loot-core/src/client/app/appSlice.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts index 79cdb8d3c0f..429ff2c4027 100644 --- a/packages/loot-core/src/client/app/appSlice.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -39,7 +39,7 @@ export const updateApp = createAppAsyncThunk( type SetAppStateAction = PayloadAction>; -const accountSlice = createSlice({ +const appSlice = createSlice({ name: 'app', initialState, reducers: { @@ -52,10 +52,10 @@ const accountSlice = createSlice({ }, }); -export const { name, reducer, getInitialState } = accountSlice; +export const { name, reducer, getInitialState } = appSlice; export const actions = { - ...accountSlice.actions, + ...appSlice.actions, updateApp, }; From 8f2789b9fd8ed381e87e1e8e1167fb5c28daf4c8 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 7 Jan 2025 15:03:37 -0800 Subject: [PATCH 18/26] Fix types --- packages/loot-core/src/client/app/appSlice.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts index 429ff2c4027..c7dd9d1b069 100644 --- a/packages/loot-core/src/client/app/appSlice.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -37,13 +37,13 @@ export const updateApp = createAppAsyncThunk( }, ); -type SetAppStateAction = PayloadAction>; +type SetAppStatePayload = Partial; const appSlice = createSlice({ name: 'app', initialState, reducers: { - setAppState(state, action: SetAppStateAction) { + setAppState(state, action: PayloadAction) { return { ...state, ...action.payload, From 5fcd62359598fa59ee1574f32a779205c06f117d Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 7 Jan 2025 15:10:14 -0800 Subject: [PATCH 19/26] Slice name --- packages/loot-core/src/client/app/appSlice.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts index c7dd9d1b069..bf6c58a6071 100644 --- a/packages/loot-core/src/client/app/appSlice.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -6,6 +6,8 @@ import { import { type AppDispatch, type RootState } from '../store'; +const sliceName = 'app'; + const createAppAsyncThunk = createAsyncThunk.withTypes<{ state: RootState; dispatch: AppDispatch; @@ -30,7 +32,7 @@ const initialState: AppState = { }; export const updateApp = createAppAsyncThunk( - 'app/updateApp', + `${sliceName}/updateApp`, async (_, thunkApi) => { await global.Actual.applyAppUpdate(); thunkApi.dispatch(setAppState({ updateInfo: null })); @@ -40,7 +42,7 @@ export const updateApp = createAppAsyncThunk( type SetAppStatePayload = Partial; const appSlice = createSlice({ - name: 'app', + name: sliceName, initialState, reducers: { setAppState(state, action: PayloadAction) { From b48b661c421f0edf38d92953e70f2be670905170 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 8 Jan 2025 13:49:40 -0800 Subject: [PATCH 20/26] Cleanup --- packages/loot-core/src/client/app/appSlice.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts index bf6c58a6071..d1f38a17752 100644 --- a/packages/loot-core/src/client/app/appSlice.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -1,18 +1,12 @@ import { - createAsyncThunk, createSlice, type PayloadAction, } from '@reduxjs/toolkit'; -import { type AppDispatch, type RootState } from '../store'; +import { createAppAsyncThunk } from '../store'; const sliceName = 'app'; -const createAppAsyncThunk = createAsyncThunk.withTypes<{ - state: RootState; - dispatch: AppDispatch; -}>(); - type AppState = { loadingText: string | null; updateInfo: { From e54d1f18128402194db672436b3f890068adad13 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 8 Jan 2025 13:56:19 -0800 Subject: [PATCH 21/26] Fix lint errors --- packages/loot-core/src/client/app/appSlice.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts index d1f38a17752..ce5929564c8 100644 --- a/packages/loot-core/src/client/app/appSlice.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -1,7 +1,4 @@ -import { - createSlice, - type PayloadAction, -} from '@reduxjs/toolkit'; +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createAppAsyncThunk } from '../store'; From db91cd021482674efae0b3c9f2b836a123336e55 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 9 Jan 2025 01:03:33 -0800 Subject: [PATCH 22/26] Fix import --- packages/loot-core/src/client/app/appSlice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts index ce5929564c8..a86060f79c7 100644 --- a/packages/loot-core/src/client/app/appSlice.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -1,6 +1,6 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import { createAppAsyncThunk } from '../store'; +import { createAppAsyncThunk } from '../redux'; const sliceName = 'app'; From a17b4270b99afe0f9994c56871d9aa624c151978 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 14 Jan 2025 09:36:02 -0800 Subject: [PATCH 23/26] Move sync actions to appSlice --- .../desktop-client/src/components/App.tsx | 3 +- .../src/components/FinancesApp.tsx | 3 +- .../src/components/Titlebar.tsx | 2 +- .../src/components/accounts/Account.tsx | 4 +- .../mobile/accounts/AccountTransactions.tsx | 4 +- .../components/mobile/accounts/Accounts.tsx | 5 +- .../src/components/mobile/budget/index.tsx | 3 +- .../modals/CreateEncryptionKeyModal.tsx | 3 +- .../src/components/settings/Reset.tsx | 2 +- .../loot-core/src/client/actions/index.ts | 1 - packages/loot-core/src/client/actions/sync.ts | 82 ---------------- packages/loot-core/src/client/app/appSlice.ts | 94 ++++++++++++++++++- .../loot-core/src/client/shared-listeners.ts | 15 ++- 13 files changed, 118 insertions(+), 103 deletions(-) delete mode 100644 packages/loot-core/src/client/actions/sync.ts diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 3a20b4bde91..44964abd946 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -17,9 +17,8 @@ import { loadBudget, loadGlobalPrefs, signOut, - sync, } from 'loot-core/client/actions'; -import { setAppState } from 'loot-core/client/app/appSlice'; +import { setAppState, sync } from 'loot-core/client/app/appSlice'; import { SpreadsheetProvider } from 'loot-core/client/SpreadsheetProvider'; import * as Platform from 'loot-core/src/client/platform'; import { diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index a1074fbf3e2..8941874c1d6 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -9,7 +9,8 @@ import { useHref, } from 'react-router-dom'; -import { addNotification, sync } from 'loot-core/client/actions'; +import { addNotification } from 'loot-core/client/actions'; +import { sync } from 'loot-core/client/app/appSlice'; import * as undo from 'loot-core/src/platform/client/undo'; import { ProtectedRoute } from '../auth/ProtectedRoute'; diff --git a/packages/desktop-client/src/components/Titlebar.tsx b/packages/desktop-client/src/components/Titlebar.tsx index 03b231f4f30..073496a0dfa 100644 --- a/packages/desktop-client/src/components/Titlebar.tsx +++ b/packages/desktop-client/src/components/Titlebar.tsx @@ -5,7 +5,7 @@ import { Routes, Route, useLocation } from 'react-router-dom'; import { css } from '@emotion/css'; -import { sync } from 'loot-core/client/actions'; +import { sync } from 'loot-core/client/app/appSlice'; import * as Platform from 'loot-core/src/client/platform'; import * as queries from 'loot-core/src/client/queries'; import { listen } from 'loot-core/src/platform/client/fetch'; diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index bfc0a08c65c..dc625f62dec 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -19,8 +19,8 @@ import { openAccountCloseModal, pushModal, replaceModal, - syncAndDownload, } from 'loot-core/client/actions'; +import { syncAndDownload } from 'loot-core/client/app/appSlice'; import { createPayee, initiallyLoadPayees, @@ -624,7 +624,7 @@ class AccountInternal extends PureComponent< const account = this.props.accounts.find(acct => acct.id === accountId); await this.props.dispatch( - syncAndDownload(account ? account.id : undefined), + syncAndDownload({ accountId: account ? account.id : undefined }), ); }; diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index afd617fc4b3..87e1051761d 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -10,8 +10,8 @@ import { collapseModals, openAccountCloseModal, pushModal, - syncAndDownload, } from 'loot-core/client/actions'; +import { syncAndDownload } from 'loot-core/client/app/appSlice'; import { accountSchedulesQuery, SchedulesProvider, @@ -258,7 +258,7 @@ function TransactionListWithPreviews({ const onRefresh = useCallback(() => { if (accountId) { - dispatch(syncAndDownload(accountId)); + dispatch(syncAndDownload({ accountId })); } }, [accountId, dispatch]); diff --git a/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx b/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx index 16cb2f958dd..2a5ff6cede8 100644 --- a/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/Accounts.tsx @@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next'; import { css } from '@emotion/css'; -import { replaceModal, syncAndDownload } from 'loot-core/src/client/actions'; +import { syncAndDownload } from 'loot-core/client/app/appSlice'; +import { replaceModal } from 'loot-core/src/client/actions'; import * as queries from 'loot-core/src/client/queries'; import { type AccountEntity } from 'loot-core/types/models'; @@ -323,7 +324,7 @@ export function Accounts() { }, [dispatch]); const onSync = useCallback(async () => { - dispatch(syncAndDownload()); + dispatch(syncAndDownload({})); }, [dispatch]); return ( diff --git a/packages/desktop-client/src/components/mobile/budget/index.tsx b/packages/desktop-client/src/components/mobile/budget/index.tsx index 2e38a0da76e..86d96330f66 100644 --- a/packages/desktop-client/src/components/mobile/budget/index.tsx +++ b/packages/desktop-client/src/components/mobile/budget/index.tsx @@ -1,7 +1,8 @@ // @ts-strict-ignore import React, { useCallback, useEffect, useState } from 'react'; -import { collapseModals, pushModal, sync } from 'loot-core/client/actions'; +import { collapseModals, pushModal } from 'loot-core/client/actions'; +import { sync } from 'loot-core/client/app/appSlice'; import { applyBudgetAction, createCategory, diff --git a/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx b/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx index 1e9c3bbcba2..f13fdd8da87 100644 --- a/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx +++ b/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx @@ -5,7 +5,8 @@ import { useTranslation, Trans } from 'react-i18next'; import { css } from '@emotion/css'; -import { loadAllFiles, loadGlobalPrefs, sync } from 'loot-core/client/actions'; +import { loadAllFiles, loadGlobalPrefs } from 'loot-core/client/actions'; +import { sync } from 'loot-core/client/app/appSlice'; import { send } from 'loot-core/src/platform/client/fetch'; import { getCreateKeyError } from 'loot-core/src/shared/errors'; diff --git a/packages/desktop-client/src/components/settings/Reset.tsx b/packages/desktop-client/src/components/settings/Reset.tsx index a9ef6e293b9..0179e400096 100644 --- a/packages/desktop-client/src/components/settings/Reset.tsx +++ b/packages/desktop-client/src/components/settings/Reset.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Trans } from 'react-i18next'; -import { resetSync } from 'loot-core/client/actions'; +import { resetSync } from 'loot-core/client/app/appSlice'; import { send } from 'loot-core/src/platform/client/fetch'; import { useMetadataPref } from '../../hooks/useMetadataPref'; diff --git a/packages/loot-core/src/client/actions/index.ts b/packages/loot-core/src/client/actions/index.ts index 5662edf4e84..9d758dfb9d8 100644 --- a/packages/loot-core/src/client/actions/index.ts +++ b/packages/loot-core/src/client/actions/index.ts @@ -3,5 +3,4 @@ export * from './notifications'; export * from './prefs'; export * from './budgets'; export * from './backups'; -export * from './sync'; export * from './user'; diff --git a/packages/loot-core/src/client/actions/sync.ts b/packages/loot-core/src/client/actions/sync.ts deleted file mode 100644 index f421b98575b..00000000000 --- a/packages/loot-core/src/client/actions/sync.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { send } from '../../platform/client/fetch'; -import { getUploadError } from '../../shared/errors'; -import { syncAccounts } from '../accounts/accountsSlice'; -import { type AppDispatch, type GetRootState } from '../store'; - -import { pushModal } from './modals'; -import { loadPrefs } from './prefs'; - -export function resetSync() { - return async (dispatch: AppDispatch) => { - const { error } = await send('sync-reset'); - - if (error) { - alert(getUploadError(error)); - - if ( - (error.reason === 'encrypt-failure' && - (error.meta as { isMissingKey?: boolean }).isMissingKey) || - error.reason === 'file-has-new-key' - ) { - dispatch( - pushModal('fix-encryption-key', { - onSuccess: () => { - // TODO: There won't be a loading indicator for this - dispatch(resetSync()); - }, - }), - ); - } else if (error.reason === 'encrypt-failure') { - dispatch(pushModal('create-encryption-key', { recreate: true })); - } - } else { - await dispatch(sync()); - } - }; -} - -export function sync() { - return async (dispatch: AppDispatch, getState: GetRootState) => { - const prefs = getState().prefs.local; - if (prefs && prefs.id) { - const result = await send('sync'); - if ('error' in result) { - return { error: result.error }; - } - - // Update the prefs - await dispatch(loadPrefs()); - } - - return {}; - }; -} - -export function syncAndDownload(accountId?: string) { - return async (dispatch: AppDispatch) => { - // 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()); - if (syncState.error) { - return { error: syncState.error }; - } - - const hasDownloaded = await dispatch(syncAccounts({ id: accountId })); - - if (hasDownloaded) { - // Sync again afterwards if new transactions were created - const syncState = await dispatch(sync()); - if (syncState.error) { - return { error: syncState.error }; - } - - // `hasDownloaded` is already true, we know there has been - // updates - return true; - } - return { hasUpdated: hasDownloaded }; - }; -} diff --git a/packages/loot-core/src/client/app/appSlice.ts b/packages/loot-core/src/client/app/appSlice.ts index a86060f79c7..819027feb0f 100644 --- a/packages/loot-core/src/client/app/appSlice.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -1,5 +1,10 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { send } from '../../platform/client/fetch'; +import { getUploadError } from '../../shared/errors'; +import { type AccountEntity } from '../../types/models'; +import { syncAccounts } from '../accounts/accountsSlice'; +import { loadPrefs, pushModal } from '../actions'; import { createAppAsyncThunk } from '../redux'; const sliceName = 'app'; @@ -24,9 +29,91 @@ const initialState: AppState = { export const updateApp = createAppAsyncThunk( `${sliceName}/updateApp`, - async (_, thunkApi) => { + async (_, { dispatch }) => { await global.Actual.applyAppUpdate(); - thunkApi.dispatch(setAppState({ updateInfo: null })); + dispatch(setAppState({ updateInfo: null })); + }, +); + +export const resetSync = createAppAsyncThunk( + `${sliceName}/resetSync`, + async (_, { dispatch }) => { + const { error } = await send('sync-reset'); + + if (error) { + alert(getUploadError(error)); + + if ( + (error.reason === 'encrypt-failure' && + (error.meta as { isMissingKey?: boolean }).isMissingKey) || + error.reason === 'file-has-new-key' + ) { + dispatch( + pushModal('fix-encryption-key', { + onSuccess: () => { + // TODO: There won't be a loading indicator for this + dispatch(resetSync()); + }, + }), + ); + } else if (error.reason === 'encrypt-failure') { + dispatch(pushModal('create-encryption-key', { recreate: true })); + } + } else { + await dispatch(sync()); + } + }, +); + +export const sync = createAppAsyncThunk( + `${sliceName}/sync`, + async (_, { dispatch, getState }) => { + const prefs = getState().prefs.local; + if (prefs && prefs.id) { + const result = await send('sync'); + if ('error' in result) { + return { error: result.error }; + } + + // Update the prefs + await dispatch(loadPrefs()); + } + + return {}; + }, +); + +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 })); + + 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 }; }, ); @@ -50,6 +137,9 @@ export const { name, reducer, getInitialState } = appSlice; export const actions = { ...appSlice.actions, updateApp, + resetSync, + sync, + syncAndDownload, }; export const { setAppState } = actions; diff --git a/packages/loot-core/src/client/shared-listeners.ts b/packages/loot-core/src/client/shared-listeners.ts index 06ea0ab453e..f4dcfcc5f0d 100644 --- a/packages/loot-core/src/client/shared-listeners.ts +++ b/packages/loot-core/src/client/shared-listeners.ts @@ -8,11 +8,10 @@ import { closeAndDownloadBudget, loadPrefs, pushModal, - resetSync, signOut, - sync, uploadBudget, } from './actions'; +import { resetSync, sync } from './app/appSlice'; import { getAccounts, getCategories, getPayees } from './queries/queriesSlice'; import type { Notification } from './state-types/notifications'; import { type AppStore } from './store'; @@ -82,7 +81,9 @@ export function listenForSyncEvent(store: AppStore) { id: 'reset-sync', button: { title: t('Reset sync'), - action: () => store.dispatch(resetSync()), + action: () => { + store.dispatch(resetSync()); + }, }, }; } else { @@ -131,7 +132,9 @@ export function listenForSyncEvent(store: AppStore) { id: 'old-file', button: { title: t('Reset sync'), - action: () => store.dispatch(resetSync()), + action: () => { + store.dispatch(resetSync()); + }, }, }; break; @@ -197,7 +200,9 @@ export function listenForSyncEvent(store: AppStore) { id: 'upload-file', button: { title: t('Upload'), - action: () => store.dispatch(resetSync()), + action: () => { + store.dispatch(resetSync()); + }, }, }; break; From bc0672e257378017f7ee47f0021e0e385d888300 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 15 Jan 2025 09:19:44 -0800 Subject: [PATCH 24/26] Revert to window --- packages/desktop-client/playwright.config.js | 8 ++++---- packages/desktop-client/src/components/FatalError.tsx | 2 +- packages/desktop-client/src/components/HelpMenu.tsx | 2 +- .../desktop-client/src/components/UpdateNotification.tsx | 2 +- .../desktop-client/src/components/accounts/Account.tsx | 4 ++-- .../src/components/manager/ConfigServer.tsx | 4 ++-- .../src/components/manager/ManagementApp.tsx | 2 +- .../ImportTransactionsModal/ImportTransactionsModal.jsx | 2 +- .../modals/manager/ConfirmChangeDocumentDir.tsx | 4 ++-- .../src/components/modals/manager/FilesSettingsModal.tsx | 2 +- .../src/components/modals/manager/ImportActualModal.tsx | 2 +- .../src/components/modals/manager/ImportYNAB4Modal.tsx | 2 +- .../src/components/modals/manager/ImportYNAB5Modal.tsx | 2 +- .../desktop-client/src/components/reports/Overview.tsx | 4 ++-- .../desktop-client/src/components/settings/Export.tsx | 2 +- packages/desktop-client/src/global-events.ts | 2 +- packages/desktop-client/src/gocardless.ts | 2 +- packages/desktop-client/src/util/versions.ts | 4 ++-- 18 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/desktop-client/playwright.config.js b/packages/desktop-client/playwright.config.js index cd075de8005..170172dc70b 100644 --- a/packages/desktop-client/playwright.config.js +++ b/packages/desktop-client/playwright.config.js @@ -26,7 +26,7 @@ expect.extend({ : locator.locator('[data-theme]'); // Check lightmode - await locator.evaluate(() => global.Actual.setTheme('auto')); + await locator.evaluate(() => window.Actual.setTheme('auto')); await expect(dataThemeLocator).toHaveAttribute('data-theme', 'auto'); const lightmode = await expect(locator).toHaveScreenshot(config); @@ -35,7 +35,7 @@ expect.extend({ } // Switch to darkmode and check - await locator.evaluate(() => global.Actual.setTheme('dark')); + await locator.evaluate(() => window.Actual.setTheme('dark')); await expect(dataThemeLocator).toHaveAttribute('data-theme', 'dark'); const darkmode = await expect(locator).toHaveScreenshot(config); @@ -45,7 +45,7 @@ expect.extend({ } // Switch to midnight theme and check - await locator.evaluate(() => global.Actual.setTheme('midnight')); + await locator.evaluate(() => window.Actual.setTheme('midnight')); await expect(dataThemeLocator).toHaveAttribute('data-theme', 'midnight'); const midnightMode = await expect(locator).toHaveScreenshot(config); @@ -55,7 +55,7 @@ expect.extend({ } // Switch back to lightmode - await locator.evaluate(() => global.Actual.setTheme('auto')); + await locator.evaluate(() => window.Actual.setTheme('auto')); return { message: () => 'pass', pass: true, diff --git a/packages/desktop-client/src/components/FatalError.tsx b/packages/desktop-client/src/components/FatalError.tsx index d2d902bdd11..6ce18aecd46 100644 --- a/packages/desktop-client/src/components/FatalError.tsx +++ b/packages/desktop-client/src/components/FatalError.tsx @@ -217,7 +217,7 @@ export function FatalError({ error }: FatalErrorProps) { )} - diff --git a/packages/desktop-client/src/components/HelpMenu.tsx b/packages/desktop-client/src/components/HelpMenu.tsx index f3086402c11..40a4208ed41 100644 --- a/packages/desktop-client/src/components/HelpMenu.tsx +++ b/packages/desktop-client/src/components/HelpMenu.tsx @@ -37,7 +37,7 @@ const getPageDocs = (page: string) => { }; function openDocsForCurrentPage() { - global.Actual.openURLInBrowser(getPageDocs(window.location.pathname)); + window.Actual.openURLInBrowser(getPageDocs(window.location.pathname)); } type HelpMenuItem = 'docs' | 'keyboard-shortcuts' | 'goal-templates'; diff --git a/packages/desktop-client/src/components/UpdateNotification.tsx b/packages/desktop-client/src/components/UpdateNotification.tsx index 0c5bb5e21af..6d5fbba55d9 100644 --- a/packages/desktop-client/src/components/UpdateNotification.tsx +++ b/packages/desktop-client/src/components/UpdateNotification.tsx @@ -69,7 +69,7 @@ export function UpdateNotification() { textDecoration: 'underline', }} onClick={() => - global.Actual.openURLInBrowser( + window.Actual.openURLInBrowser( 'https://actualbudget.org/docs/releases', ) } diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index dc625f62dec..643a11286bb 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -633,7 +633,7 @@ class AccountInternal extends PureComponent< const account = this.props.accounts.find(acct => acct.id === accountId); if (account) { - const res = await global.Actual.openFileDialog({ + const res = await window.Actual.openFileDialog({ filters: [ { name: t('Financial Files'), @@ -668,7 +668,7 @@ class AccountInternal extends PureComponent< accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-'); const filename = `${normalizedName || 'transactions'}.csv`; - global.Actual.saveFile( + window.Actual.saveFile( exportedTransactions, filename, t('Export Transactions'), diff --git a/packages/desktop-client/src/components/manager/ConfigServer.tsx b/packages/desktop-client/src/components/manager/ConfigServer.tsx index c3bbb5a4b6e..107676551bb 100644 --- a/packages/desktop-client/src/components/manager/ConfigServer.tsx +++ b/packages/desktop-client/src/components/manager/ConfigServer.tsx @@ -35,7 +35,7 @@ export function ConfigServer() { const [error, setError] = useState(null); const restartElectronServer = useCallback(() => { - global.Actual.restartElectronServer(); + globalThis.window.Actual.restartElectronServer(); setError(null); }, []); @@ -88,7 +88,7 @@ export function ConfigServer() { } async function onSelectSelfSignedCertificate() { - const selfSignedCertificateLocation = await global.Actual.openFileDialog({ + const selfSignedCertificateLocation = await window.Actual.openFileDialog({ properties: ['openFile'], filters: [ { diff --git a/packages/desktop-client/src/components/manager/ManagementApp.tsx b/packages/desktop-client/src/components/manager/ManagementApp.tsx index 3ff6f8ce49e..12d2c79ffe0 100644 --- a/packages/desktop-client/src/components/manager/ManagementApp.tsx +++ b/packages/desktop-client/src/components/manager/ManagementApp.tsx @@ -52,7 +52,7 @@ function Version() { }, }} > - {`App: v${global.Actual.ACTUAL_VERSION} | Server: ${version}`} + {`App: v${window.Actual.ACTUAL_VERSION} | Server: ${version}`} ); } diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx index 6f8daa1c04b..1abef1d39d8 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx +++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx @@ -471,7 +471,7 @@ export function ImportTransactionsModal({ options }) { } async function onNewFile() { - const res = await global.Actual.openFileDialog({ + const res = await window.Actual.openFileDialog({ filters: [ { name: 'Financial Files', diff --git a/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx b/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx index b1702dfdcf4..87f930acca4 100644 --- a/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx +++ b/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx @@ -51,7 +51,7 @@ export function ConfirmChangeDocumentDirModal({ const dispatch = useDispatch(); const restartElectronServer = useCallback(() => { - global.Actual.restartElectronServer(); + window.Actual.restartElectronServer(); }, []); const [_documentDir, setDocumentDirPref] = useGlobalPref( @@ -64,7 +64,7 @@ export function ConfirmChangeDocumentDirModal({ setLoading(true); try { if (moveFiles) { - await global.Actual.moveBudgetDirectory( + await window.Actual.moveBudgetDirectory( currentBudgetDirectory, newDirectory, ); diff --git a/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx index 476c329602b..d6275e817fd 100644 --- a/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx +++ b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx @@ -19,7 +19,7 @@ function FileLocationSettings() { const dispatch = useDispatch(); async function onChooseDocumentDir() { - const chosenDirectory = await global.Actual.openFileDialog({ + const chosenDirectory = await window.Actual.openFileDialog({ properties: ['openDirectory'], }); diff --git a/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx b/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx index 733f970620b..87421f22886 100644 --- a/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx +++ b/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx @@ -38,7 +38,7 @@ export function ImportActualModal() { const [importing, setImporting] = useState(false); async function onImport() { - const res = await global.Actual.openFileDialog({ + const res = await window.Actual.openFileDialog({ properties: ['openFile'], filters: [{ name: 'actual', extensions: ['zip', 'blob'] }], }); diff --git a/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx b/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx index e6f11670660..65020cb063e 100644 --- a/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx +++ b/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx @@ -30,7 +30,7 @@ export function ImportYNAB4Modal() { const [importing, setImporting] = useState(false); async function onImport() { - const res = await global.Actual.openFileDialog({ + const res = await window.Actual.openFileDialog({ properties: ['openFile'], filters: [{ name: 'ynab', extensions: ['zip'] }], }); diff --git a/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx b/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx index 773d0181a27..3b8e4752e31 100644 --- a/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx +++ b/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx @@ -33,7 +33,7 @@ export function ImportYNAB5Modal() { const [importing, setImporting] = useState(false); async function onImport() { - const res = await global.Actual.openFileDialog({ + const res = await window.Actual.openFileDialog({ properties: ['openFile'], filters: [{ name: 'ynab', extensions: ['json'] }], }); diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index 558f20aed66..f3e467c7473 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -211,14 +211,14 @@ export function Overview() { }), } satisfies ExportImportDashboard; - global.Actual.saveFile( + window.Actual.saveFile( JSON.stringify(data, null, 2), 'dashboard.json', 'Export Dashboard', ); }; const onImport = async () => { - const openFileDialog = global.Actual.openFileDialog; + const openFileDialog = window.Actual.openFileDialog; if (!openFileDialog) { dispatch( diff --git a/packages/desktop-client/src/components/settings/Export.tsx b/packages/desktop-client/src/components/settings/Export.tsx index 2b43a22f386..2f2a33dbb11 100644 --- a/packages/desktop-client/src/components/settings/Export.tsx +++ b/packages/desktop-client/src/components/settings/Export.tsx @@ -33,7 +33,7 @@ export function ExportBudget() { return; } - global.Actual.saveFile( + window.Actual.saveFile( response.data, `${format(new Date(), 'yyyy-MM-dd')}-${budgetName}.zip`, t('Export budget'), diff --git a/packages/desktop-client/src/global-events.ts b/packages/desktop-client/src/global-events.ts index b0fb7b3d66f..ed8d7b3311d 100644 --- a/packages/desktop-client/src/global-events.ts +++ b/packages/desktop-client/src/global-events.ts @@ -162,6 +162,6 @@ export function handleGlobalEvents(store: AppStore) { }); listen('api-fetch-redirected', () => { - global.Actual.reload(); + window.Actual.reload(); }); } diff --git a/packages/desktop-client/src/gocardless.ts b/packages/desktop-client/src/gocardless.ts index b76c3dea751..fc92c403f36 100644 --- a/packages/desktop-client/src/gocardless.ts +++ b/packages/desktop-client/src/gocardless.ts @@ -25,7 +25,7 @@ function _authorize( if ('error' in resp) return resp; const { link, requisitionId } = resp; - global.Actual.openURLInBrowser(link); + window.Actual.openURLInBrowser(link); return send('gocardless-poll-web-token', { upgradingAccountId, diff --git a/packages/desktop-client/src/util/versions.ts b/packages/desktop-client/src/util/versions.ts index 0c9d8e5d30e..291d236403f 100644 --- a/packages/desktop-client/src/util/versions.ts +++ b/packages/desktop-client/src/util/versions.ts @@ -30,7 +30,7 @@ export async function getLatestVersion(): Promise { const json = await response.json(); const tags = json .map(t => t.name) - .concat([`v${global.Actual.ACTUAL_VERSION}`]); + .concat([`v${window.Actual.ACTUAL_VERSION}`]); tags.sort(cmpSemanticVersion); return tags[tags.length - 1]; @@ -41,7 +41,7 @@ export async function getLatestVersion(): Promise { } export async function getIsOutdated(latestVersion: string): Promise { - const clientVersion = global.Actual.ACTUAL_VERSION; + const clientVersion = window.Actual.ACTUAL_VERSION; if (latestVersion === 'unknown') { return Promise.resolve(false); } From 42fa527dd1569e1f2c8a716553c936e7425c5376 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 17 Jan 2025 11:09:55 -0800 Subject: [PATCH 25/26] Updates --- packages/desktop-client/src/browser-preload.browser.js | 8 +++----- .../modals/manager/ConfirmChangeDocumentDir.tsx | 4 ++-- packages/desktop-client/src/index.tsx | 9 +++++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/desktop-client/src/browser-preload.browser.js b/packages/desktop-client/src/browser-preload.browser.js index 346b0d396ad..373cd7de7fc 100644 --- a/packages/desktop-client/src/browser-preload.browser.js +++ b/packages/desktop-client/src/browser-preload.browser.js @@ -2,7 +2,6 @@ import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-th // eslint-disable-next-line import/no-unresolved import { registerSW } from 'virtual:pwa-register'; -import { send } from 'loot-core/platform/client/fetch'; import * as Platform from 'loot-core/src/client/platform'; import packageJson from '../package.json'; @@ -122,10 +121,9 @@ global.Actual = { reader.readAsArrayBuffer(file); reader.onload = async function (ev) { const filepath = `/uploads/${filename}`; - send('upload-file-web', { - filename, - contents: ev.target.result, - }).then(() => resolve([filepath])); + window.__actionsForMenu + .uploadFile(filename, ev.target.result) + .then(() => resolve([filepath])); }; reader.onerror = function () { alert('Error reading file'); diff --git a/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx b/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx index 87f930acca4..c3c303dc039 100644 --- a/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx +++ b/packages/desktop-client/src/components/modals/manager/ConfirmChangeDocumentDir.tsx @@ -51,7 +51,7 @@ export function ConfirmChangeDocumentDirModal({ const dispatch = useDispatch(); const restartElectronServer = useCallback(() => { - window.Actual.restartElectronServer(); + globalThis.window.Actual.restartElectronServer(); }, []); const [_documentDir, setDocumentDirPref] = useGlobalPref( @@ -64,7 +64,7 @@ export function ConfirmChangeDocumentDirModal({ setLoading(true); try { if (moveFiles) { - await window.Actual.moveBudgetDirectory( + await globalThis.window.Actual.moveBudgetDirectory( currentBudgetDirectory, newDirectory, ); diff --git a/packages/desktop-client/src/index.tsx b/packages/desktop-client/src/index.tsx index a06cb0ecf82..0ebe143d9cb 100644 --- a/packages/desktop-client/src/index.tsx +++ b/packages/desktop-client/src/index.tsx @@ -49,6 +49,13 @@ async function appFocused() { await send('app-focused'); } +async function uploadFile(filename: string, contents: ArrayBuffer) { + send('upload-file-web', { + filename, + contents, + }); +} + function inputFocused() { return ( window.document.activeElement.tagName === 'INPUT' || @@ -64,6 +71,7 @@ window.__actionsForMenu = { redo, appFocused, inputFocused, + uploadFile, }; // Expose send for fun! @@ -91,6 +99,7 @@ declare global { redo: typeof redo; appFocused: typeof appFocused; inputFocused: typeof inputFocused; + uploadFile: typeof uploadFile; }; $send: typeof send; From c53d12fedb9cc01c727beedcdf1b2649e4fbb7cd Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 17 Jan 2025 11:11:12 -0800 Subject: [PATCH 26/26] Revert browser-preload.browser.js --- packages/desktop-client/src/browser-preload.browser.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop-client/src/browser-preload.browser.js b/packages/desktop-client/src/browser-preload.browser.js index 373cd7de7fc..69834cca51c 100644 --- a/packages/desktop-client/src/browser-preload.browser.js +++ b/packages/desktop-client/src/browser-preload.browser.js @@ -121,6 +121,7 @@ global.Actual = { reader.readAsArrayBuffer(file); reader.onload = async function (ev) { const filepath = `/uploads/${filename}`; + window.__actionsForMenu .uploadFile(filename, ev.target.result) .then(() => resolve([filepath]));