diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index fafac793317..44964abd946 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -16,10 +16,9 @@ import { closeBudget, loadBudget, loadGlobalPrefs, - setAppState, signOut, - sync, } from 'loot-core/client/actions'; +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/FatalError.tsx b/packages/desktop-client/src/components/FatalError.tsx index 682ba97aa24..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/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/HelpMenu.tsx b/packages/desktop-client/src/components/HelpMenu.tsx index 11c292add30..40a4208ed41 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() { + window.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/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/UpdateNotification.tsx b/packages/desktop-client/src/components/UpdateNotification.tsx index 469b76b98b8..6d5fbba55d9 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( + 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 08b6b397f12..643a11286bb 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 }), ); }; @@ -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 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`; - window.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 a8020f083dc..107676551bb 100644 --- a/packages/desktop-client/src/components/manager/ConfigServer.tsx +++ b/packages/desktop-client/src/components/manager/ConfigServer.tsx @@ -88,7 +88,7 @@ export function ConfigServer() { } async function onSelectSelfSignedCertificate() { - const selfSignedCertificateLocation = await window.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 872bf98363a..12d2c79ffe0 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${window.Actual.ACTUAL_VERSION} | Server: ${version}`} ); } 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/modals/ImportTransactionsModal/ImportTransactionsModal.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx index dceb8312640..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 window.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 adf3e5dfe35..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(() => { - globalThis.window.Actual?.restartElectronServer(); + globalThis.window.Actual.restartElectronServer(); }, []); const [_documentDir, setDocumentDirPref] = useGlobalPref( @@ -64,7 +64,7 @@ export function ConfirmChangeDocumentDirModal({ setLoading(true); try { if (moveFiles) { - await globalThis.window.Actual?.moveBudgetDirectory( + await globalThis.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 ee3c1107def..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 window.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 995154b13e7..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 window.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 49a80721bf9..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 window.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 8945278e80a..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 window.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 36023b89b6a..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; - window.Actual?.saveFile( + window.Actual.saveFile( JSON.stringify(data, null, 2), 'dashboard.json', 'Export Dashboard', ); }; const onImport = async () => { - const openFileDialog = window.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 393752a7b2d..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; } - window.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/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/desktop-client/src/global-events.ts b/packages/desktop-client/src/global-events.ts index 3bd98bd6c4b..ed8d7b3311d 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()); + window.Actual.reload(); }); } diff --git a/packages/desktop-client/src/gocardless.ts b/packages/desktop-client/src/gocardless.ts index dc4d48b6332..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; - window.Actual?.openURLInBrowser(link); + window.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..0ebe143d9cb 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,15 @@ 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; - }; +async function appFocused() { + await send('app-focused'); +} - $send: typeof send; - $query: typeof runQuery; - $q: typeof q; - } +async function uploadFile(filename: string, contents: ArrayBuffer) { + send('upload-file-web', { + filename, + contents, + }); } function inputFocused() { @@ -67,7 +65,14 @@ 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, + uploadFile, +}; // Expose send for fun! window.$send = send; @@ -85,3 +90,20 @@ 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; + uploadFile: typeof uploadFile; + }; + + $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..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${window.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 = window.Actual?.ACTUAL_VERSION; + const clientVersion = window.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..9d758dfb9d8 100644 --- a/packages/loot-core/src/client/actions/index.ts +++ b/packages/loot-core/src/client/actions/index.ts @@ -2,7 +2,5 @@ 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/actions/sync.ts b/packages/loot-core/src/client/app/appSlice.ts similarity index 50% rename from packages/loot-core/src/client/actions/sync.ts rename to packages/loot-core/src/client/app/appSlice.ts index f421b98575b..819027feb0f 100644 --- a/packages/loot-core/src/client/actions/sync.ts +++ b/packages/loot-core/src/client/app/appSlice.ts @@ -1,13 +1,43 @@ +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 { type AppDispatch, type GetRootState } from '../store'; +import { loadPrefs, pushModal } from '../actions'; +import { createAppAsyncThunk } from '../redux'; + +const sliceName = 'app'; + +type AppState = { + loadingText: string | null; + updateInfo: { + version: string; + releaseDate: string; + releaseNotes: string; + } | null; + showUpdateNotification: boolean; + managerHasInitialized: boolean; +}; -import { pushModal } from './modals'; -import { loadPrefs } from './prefs'; +const initialState: AppState = { + loadingText: null, + updateInfo: null, + showUpdateNotification: true, + managerHasInitialized: false, +}; -export function resetSync() { - return async (dispatch: AppDispatch) => { +export const updateApp = createAppAsyncThunk( + `${sliceName}/updateApp`, + async (_, { dispatch }) => { + await global.Actual.applyAppUpdate(); + dispatch(setAppState({ updateInfo: null })); + }, +); + +export const resetSync = createAppAsyncThunk( + `${sliceName}/resetSync`, + async (_, { dispatch }) => { const { error } = await send('sync-reset'); if (error) { @@ -32,11 +62,12 @@ export function resetSync() { } else { await dispatch(sync()); } - }; -} + }, +); -export function sync() { - return async (dispatch: AppDispatch, getState: GetRootState) => { +export const sync = createAppAsyncThunk( + `${sliceName}/sync`, + async (_, { dispatch, getState }) => { const prefs = getState().prefs.local; if (prefs && prefs.id) { const result = await send('sync'); @@ -49,17 +80,22 @@ export function sync() { } return {}; - }; -} + }, +); + +type SyncAndDownloadPayload = { + accountId?: AccountEntity['id'] | string; +}; -export function syncAndDownload(accountId?: string) { - return async (dispatch: AppDispatch) => { +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()); + const syncState = await dispatch(sync()).unwrap(); if (syncState.error) { return { error: syncState.error }; } @@ -68,7 +104,7 @@ export function syncAndDownload(accountId?: string) { if (hasDownloaded) { // Sync again afterwards if new transactions were created - const syncState = await dispatch(sync()); + const syncState = await dispatch(sync()).unwrap(); if (syncState.error) { return { error: syncState.error }; } @@ -78,5 +114,32 @@ export function syncAndDownload(accountId?: string) { return true; } return { hasUpdated: hasDownloaded }; - }; -} + }, +); + +type SetAppStatePayload = Partial; + +const appSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + setAppState(state, action: PayloadAction) { + return { + ...state, + ...action.payload, + }; + }, + }, +}); + +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/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/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/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; 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; 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..afe10ea6224 100644 --- a/packages/loot-core/typings/window.d.ts +++ b/packages/loot-core/typings/window.d.ts @@ -1,29 +1,44 @@ 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; + startOAuthServer: () => 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; } 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