diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx
index b7e6391253c..72aa0150316 100644
--- a/packages/desktop-client/src/components/App.tsx
+++ b/packages/desktop-client/src/components/App.tsx
@@ -13,8 +13,6 @@ import { BrowserRouter } from 'react-router-dom';
import {
addNotification,
- closeBudget,
- loadBudget,
loadGlobalPrefs,
signOut,
sync,
@@ -44,6 +42,7 @@ import { Modals } from './Modals';
import { ResponsiveProvider } from './responsive/ResponsiveProvider';
import { SidebarProvider } from './sidebar/SidebarProvider';
import { UpdateNotification } from './UpdateNotification';
+import { closeBudget, loadBudget } from 'loot-core/client/budgets/budgetsSlice';
function AppInner() {
const [budgetId] = useMetadataPref('id');
@@ -91,7 +90,7 @@ function AppInner() {
);
const budgetId = await send('get-last-opened-backup');
if (budgetId) {
- await dispatch(loadBudget(budgetId));
+ await dispatch(loadBudget({ id: budgetId }));
// Check to see if this file has been remotely deleted (but
// don't block on this in case they are offline or something)
diff --git a/packages/desktop-client/src/components/LoggedInUser.tsx b/packages/desktop-client/src/components/LoggedInUser.tsx
index 6bc78bcbd22..67a333b601d 100644
--- a/packages/desktop-client/src/components/LoggedInUser.tsx
+++ b/packages/desktop-client/src/components/LoggedInUser.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, type CSSProperties } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
-import { closeBudget, getUserData, signOut } from 'loot-core/client/actions';
+import { getUserData, signOut } from 'loot-core/client/actions';
import { type RemoteFile, type SyncedLocalFile } from 'loot-core/types/file';
import { type TransObjectLiteral } from 'loot-core/types/util';
@@ -20,6 +20,7 @@ import { Text } from './common/Text';
import { View } from './common/View';
import { PrivacyFilter } from './PrivacyFilter';
import { useMultiuserEnabled, useServerURL } from './ServerContext';
+import { closeBudget } from 'loot-core/client/budgets/budgetsSlice';
type LoggedInUserProps = {
hideIfNoServer?: boolean;
diff --git a/packages/desktop-client/src/components/manager/BudgetList.tsx b/packages/desktop-client/src/components/manager/BudgetList.tsx
index 502b6f7f10a..5997611ad58 100644
--- a/packages/desktop-client/src/components/manager/BudgetList.tsx
+++ b/packages/desktop-client/src/components/manager/BudgetList.tsx
@@ -8,13 +8,7 @@ import React, {
import { Trans, useTranslation } from 'react-i18next';
import {
- closeAndDownloadBudget,
- closeAndLoadBudget,
- createBudget,
- downloadBudget,
getUserData,
- loadAllFiles,
- loadBudget,
pushModal,
} from 'loot-core/client/actions';
import {
@@ -53,6 +47,7 @@ import { Tooltip } from '../common/Tooltip';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
import { useMultiuserEnabled } from '../ServerContext';
+import { closeAndDownloadBudget, closeAndLoadBudget, createBudget, downloadBudget, loadAllFiles, loadBudget } from 'loot-core/client/budgets/budgetsSlice';
function getFileDescription(file: File, t: (key: string) => string) {
if (file.state === 'unknown') {
@@ -559,14 +554,14 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
if (!id) {
if (isRemoteFile) {
- await dispatch(downloadBudget(file.cloudFileId));
+ await dispatch(downloadBudget({ cloudFileId: file.cloudFileId }));
} else {
- await dispatch(loadBudget(file.id));
+ await dispatch(loadBudget({ id: file.id }));
}
} else if (!isRemoteFile && file.id !== id) {
- await dispatch(closeAndLoadBudget(file.id));
+ await dispatch(closeAndLoadBudget({ fileId: file.id }));
} else if (isRemoteFile) {
- await dispatch(closeAndDownloadBudget(file.cloudFileId));
+ await dispatch(closeAndDownloadBudget({ cloudFileId: file.cloudFileId }));
}
};
diff --git a/packages/desktop-client/src/components/manager/ConfigServer.tsx b/packages/desktop-client/src/components/manager/ConfigServer.tsx
index 417736fa3f3..ed8f9b92f0f 100644
--- a/packages/desktop-client/src/components/manager/ConfigServer.tsx
+++ b/packages/desktop-client/src/components/manager/ConfigServer.tsx
@@ -2,7 +2,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { createBudget, loggedIn, signOut } from 'loot-core/client/actions';
+import { loggedIn, signOut } from 'loot-core/client/actions';
import {
isNonProductionEnvironment,
isElectron,
@@ -20,6 +20,7 @@ import { View } from '../common/View';
import { useServerURL, useSetServerURL } from '../ServerContext';
import { Title } from './subscribe/common';
+import { createBudget } from 'loot-core/client/budgets/budgetsSlice';
export function ConfigServer() {
const { t } = useTranslation();
diff --git a/packages/desktop-client/src/components/manager/WelcomeScreen.tsx b/packages/desktop-client/src/components/manager/WelcomeScreen.tsx
index f2d64d6d845..31499c75de2 100644
--- a/packages/desktop-client/src/components/manager/WelcomeScreen.tsx
+++ b/packages/desktop-client/src/components/manager/WelcomeScreen.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { createBudget, pushModal } from 'loot-core/client/actions';
+import { pushModal } from 'loot-core/client/actions';
import { useAppDispatch } from '../../redux';
import { styles, theme } from '../../style';
@@ -10,6 +10,7 @@ import { Link } from '../common/Link';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { View } from '../common/View';
+import { createBudget } from 'loot-core/client/budgets/budgetsSlice';
export function WelcomeScreen() {
const { t } = useTranslation();
@@ -95,7 +96,7 @@ export function WelcomeScreen() {
diff --git a/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx b/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx
index 6dbbb226073..2e6179d2ba7 100644
--- a/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx
+++ b/packages/desktop-client/src/components/manager/subscribe/Bootstrap.tsx
@@ -2,7 +2,6 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { createBudget } from 'loot-core/src/client/actions/budgets';
import { send } from 'loot-core/src/platform/client/fetch';
import { useNavigate } from '../../../hooks/useNavigate';
@@ -17,6 +16,7 @@ import { useRefreshLoginMethods } from '../../ServerContext';
import { useBootstrapped, Title } from './common';
import { ConfirmPasswordForm } from './ConfirmPasswordForm';
+import { createBudget } from 'loot-core/client/budgets/budgetsSlice';
export function Bootstrap() {
const { t } = useTranslation();
diff --git a/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx b/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx
index 2773f62e467..d3f42e8eafc 100644
--- a/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx
+++ b/packages/desktop-client/src/components/modals/CreateEncryptionKeyModal.tsx
@@ -5,7 +5,7 @@ import { useTranslation, Trans } from 'react-i18next';
import { css } from '@emotion/css';
-import { loadAllFiles, loadGlobalPrefs, sync } from 'loot-core/client/actions';
+import { loadGlobalPrefs, sync } from 'loot-core/client/actions';
import { send } from 'loot-core/src/platform/client/fetch';
import { getCreateKeyError } from 'loot-core/src/shared/errors';
@@ -25,6 +25,7 @@ import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
+import { loadAllFiles } from 'loot-core/client/budgets/budgetsSlice';
type CreateEncryptionKeyModalProps = {
options: {
diff --git a/packages/desktop-client/src/components/modals/OpenIDEnableModal.tsx b/packages/desktop-client/src/components/modals/OpenIDEnableModal.tsx
index 9a514146db8..75870615ee5 100644
--- a/packages/desktop-client/src/components/modals/OpenIDEnableModal.tsx
+++ b/packages/desktop-client/src/components/modals/OpenIDEnableModal.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { closeBudget, popModal } from 'loot-core/client/actions';
+import { popModal } from 'loot-core/client/actions';
import { send } from 'loot-core/platform/client/fetch';
import * as asyncStorage from 'loot-core/platform/server/asyncStorage';
import { getOpenIdErrors } from 'loot-core/shared/errors';
@@ -16,6 +16,7 @@ import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { OpenIdForm } from '../manager/subscribe/OpenIdForm';
import { useRefreshLoginMethods } from '../ServerContext';
+import { closeBudget } from 'loot-core/client/budgets/budgetsSlice';
type OpenIDEnableModalProps = {
onSave?: () => void;
diff --git a/packages/desktop-client/src/components/modals/OutOfSyncMigrationsModal.tsx b/packages/desktop-client/src/components/modals/OutOfSyncMigrationsModal.tsx
index 2461da9cc9b..d2c38af6f19 100644
--- a/packages/desktop-client/src/components/modals/OutOfSyncMigrationsModal.tsx
+++ b/packages/desktop-client/src/components/modals/OutOfSyncMigrationsModal.tsx
@@ -1,8 +1,6 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { closeBudget } from 'loot-core/client/actions';
-
import { useAppDispatch } from '../../redux';
import { Button } from '../common/Button2';
import { Link } from '../common/Link';
@@ -10,6 +8,7 @@ import { Modal, ModalHeader, ModalTitle } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { View } from '../common/View';
+import { closeBudget } from 'loot-core/client/budgets/budgetsSlice';
export function OutOfSyncMigrationsModal() {
const dispatch = useAppDispatch();
diff --git a/packages/desktop-client/src/components/modals/PasswordEnableModal.tsx b/packages/desktop-client/src/components/modals/PasswordEnableModal.tsx
index 89460a308f1..91a1ee32ed9 100644
--- a/packages/desktop-client/src/components/modals/PasswordEnableModal.tsx
+++ b/packages/desktop-client/src/components/modals/PasswordEnableModal.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { closeBudget, popModal } from 'loot-core/client/actions';
+import { popModal } from 'loot-core/client/actions';
import { send } from 'loot-core/platform/client/fetch';
import * as asyncStorage from 'loot-core/src/platform/server/asyncStorage';
@@ -22,6 +22,7 @@ import {
useMultiuserEnabled,
useRefreshLoginMethods,
} from '../ServerContext';
+import { closeBudget } from 'loot-core/client/budgets/budgetsSlice';
type PasswordEnableModalProps = {
onSave?: () => void;
diff --git a/packages/desktop-client/src/components/modals/TransferOwnership.tsx b/packages/desktop-client/src/components/modals/TransferOwnership.tsx
index 861159b17bd..97915bac8f1 100644
--- a/packages/desktop-client/src/components/modals/TransferOwnership.tsx
+++ b/packages/desktop-client/src/components/modals/TransferOwnership.tsx
@@ -3,7 +3,6 @@ import { Trans, useTranslation } from 'react-i18next';
import {
addNotification,
- closeAndLoadBudget,
popModal,
} from 'loot-core/client/actions';
import { send } from 'loot-core/platform/client/fetch';
@@ -22,6 +21,7 @@ import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FormField, FormLabel } from '../forms';
+import { closeAndLoadBudget } from 'loot-core/client/budgets/budgetsSlice';
type TransferOwnershipProps = {
onSave?: () => void;
@@ -186,7 +186,7 @@ export function TransferOwnership({
try {
await onSave();
await dispatch(
- closeAndLoadBudget((currentFile as Budget).id),
+ closeAndLoadBudget({ fileId: (currentFile as Budget).id }),
);
close();
} catch (error) {
diff --git a/packages/desktop-client/src/components/modals/manager/DeleteFileModal.tsx b/packages/desktop-client/src/components/modals/manager/DeleteFileModal.tsx
index 3c482d43744..5b1d3204961 100644
--- a/packages/desktop-client/src/components/modals/manager/DeleteFileModal.tsx
+++ b/packages/desktop-client/src/components/modals/manager/DeleteFileModal.tsx
@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { deleteBudget } from 'loot-core/client/actions';
import { type File } from 'loot-core/src/types/file';
import { useAppDispatch } from '../../../redux';
@@ -10,6 +9,7 @@ import { ButtonWithLoading } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
+import { deleteBudget } from 'loot-core/client/budgets/budgetsSlice';
type DeleteFileProps = {
file: File;
@@ -70,10 +70,10 @@ export function DeleteFileModal({ file }: DeleteFileProps) {
onPress={async () => {
setLoadingState('cloud');
await dispatch(
- deleteBudget(
- 'id' in file ? file.id : undefined,
- file.cloudFileId,
- ),
+ deleteBudget({
+ id: 'id' in file ? file.id : undefined,
+ cloudFileId: file.cloudFileId,
+ }),
);
setLoadingState(null);
@@ -136,7 +136,7 @@ export function DeleteFileModal({ file }: DeleteFileProps) {
}}
onPress={async () => {
setLoadingState('local');
- await dispatch(deleteBudget(file.id));
+ await dispatch(deleteBudget({ id: file.id }));
setLoadingState(null);
close();
diff --git a/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx b/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx
index 5e0a9ffbf07..977aca2d551 100644
--- a/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx
+++ b/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx
@@ -1,12 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import {
- addNotification,
- duplicateBudget,
- uniqueBudgetName,
- validateBudgetName,
-} from 'loot-core/client/actions';
+import { addNotification } from 'loot-core/client/actions';
import { type File } from 'loot-core/src/types/file';
import { useAppDispatch } from '../../../redux';
@@ -24,6 +19,8 @@ import {
} from '../../common/Modal';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
+import { send } from 'loot-core/platform/client/fetch';
+import { duplicateBudget } from 'loot-core/client/budgets/budgetsSlice';
type DuplicateFileProps = {
file: File;
@@ -241,3 +238,14 @@ export function DuplicateFileModal({
);
}
+
+function validateBudgetName(name: string): {
+ valid: boolean;
+ message?: string;
+} {
+ return send('validate-budget-name', { name });
+}
+
+function uniqueBudgetName(name: string): string {
+ return send('unique-budget-name', { name });
+}
\ No newline at end of file
diff --git a/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx
index 4f556b41b69..da1c2edb1a3 100644
--- a/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx
+++ b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { loadAllFiles, pushModal } from 'loot-core/client/actions';
+import { pushModal } from 'loot-core/client/actions';
import { useGlobalPref } from '../../../hooks/useGlobalPref';
import { SvgPencil1 } from '../../../icons/v2';
@@ -11,6 +11,7 @@ import { Button } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
+import { loadAllFiles } from 'loot-core/client/budgets/budgetsSlice';
function FileLocationSettings() {
const [documentDir, _setDocumentDirPref] = useGlobalPref('documentDir');
diff --git a/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx b/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx
index ec46f9e62b3..0281af403c5 100644
--- a/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx
+++ b/packages/desktop-client/src/components/modals/manager/ImportActualModal.tsx
@@ -2,8 +2,6 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { importBudget } from 'loot-core/src/client/actions/budgets';
-
import { useNavigate } from '../../../hooks/useNavigate';
import { useAppDispatch } from '../../../redux';
import { styles, theme } from '../../../style';
@@ -12,6 +10,7 @@ import { ButtonWithLoading } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Paragraph } from '../../common/Paragraph';
import { View } from '../../common/View';
+import { importBudget } from 'loot-core/client/budgets/budgetsSlice';
function getErrorMessage(error: string): string {
switch (error) {
@@ -46,7 +45,7 @@ export function ImportActualModal() {
setImporting(true);
setError(null);
try {
- await dispatch(importBudget(res[0], 'actual'));
+ await dispatch(importBudget({ filepath: res[0], type: 'actual' }));
navigate('/budget');
} catch (err) {
setError(err.message);
diff --git a/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx b/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx
index a421671ca48..df504c9ac23 100644
--- a/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx
+++ b/packages/desktop-client/src/components/modals/manager/ImportYNAB4Modal.tsx
@@ -2,8 +2,6 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { importBudget } from 'loot-core/src/client/actions/budgets';
-
import { useNavigate } from '../../../hooks/useNavigate';
import { useAppDispatch } from '../../../redux';
import { styles, theme } from '../../../style';
@@ -12,6 +10,7 @@ import { ButtonWithLoading } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Paragraph } from '../../common/Paragraph';
import { View } from '../../common/View';
+import { importBudget } from 'loot-core/client/budgets/budgetsSlice';
function getErrorMessage(error: string): string {
switch (error) {
@@ -38,7 +37,7 @@ export function ImportYNAB4Modal() {
setImporting(true);
setError(null);
try {
- await dispatch(importBudget(res[0], 'ynab4'));
+ await dispatch(importBudget({ filepath: res[0], type: 'ynab4' }));
navigate('/budget');
} catch (err) {
setError(err.message);
diff --git a/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx b/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx
index 0138ceb812a..3a0ec9ebd8b 100644
--- a/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx
+++ b/packages/desktop-client/src/components/modals/manager/ImportYNAB5Modal.tsx
@@ -2,8 +2,6 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { importBudget } from 'loot-core/src/client/actions/budgets';
-
import { useNavigate } from '../../../hooks/useNavigate';
import { useAppDispatch } from '../../../redux';
import { styles, theme } from '../../../style';
@@ -13,6 +11,7 @@ import { Link } from '../../common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Paragraph } from '../../common/Paragraph';
import { View } from '../../common/View';
+import { importBudget } from 'loot-core/client/budgets/budgetsSlice';
function getErrorMessage(error: string): string {
switch (error) {
@@ -41,7 +40,7 @@ export function ImportYNAB5Modal() {
setImporting(true);
setError(null);
try {
- await dispatch(importBudget(res[0], 'ynab5'));
+ await dispatch(importBudget({ filepath: res[0], type: 'ynab5' }));
navigate('/budget');
} catch (err) {
setError(err.message);
diff --git a/packages/desktop-client/src/components/settings/index.tsx b/packages/desktop-client/src/components/settings/index.tsx
index 88257911aac..f75933bfee6 100644
--- a/packages/desktop-client/src/components/settings/index.tsx
+++ b/packages/desktop-client/src/components/settings/index.tsx
@@ -3,7 +3,7 @@ import { useTranslation, Trans } from 'react-i18next';
import { css } from '@emotion/css';
-import { closeBudget, loadPrefs } from 'loot-core/client/actions';
+import { loadPrefs } from 'loot-core/client/actions';
import { isElectron } from 'loot-core/shared/environment';
import { listen } from 'loot-core/src/platform/client/fetch';
@@ -35,6 +35,7 @@ import { FormatSettings } from './Format';
import { ResetCache, ResetSync } from './Reset';
import { ThemeSettings } from './Themes';
import { AdvancedToggle, Setting } from './UI';
+import { closeBudget } from 'loot-core/client/budgets/budgetsSlice';
function About() {
const version = useServerVersion();
diff --git a/packages/desktop-client/src/components/sidebar/BudgetName.tsx b/packages/desktop-client/src/components/sidebar/BudgetName.tsx
index dddfdb6f1c9..ad770e07620 100644
--- a/packages/desktop-client/src/components/sidebar/BudgetName.tsx
+++ b/packages/desktop-client/src/components/sidebar/BudgetName.tsx
@@ -1,7 +1,6 @@
import React, { type ReactNode, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { closeBudget } from 'loot-core/src/client/actions';
import * as Platform from 'loot-core/src/client/platform';
import { useContextMenu } from '../../hooks/useContextMenu';
@@ -17,6 +16,7 @@ import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
+import { closeBudget } from 'loot-core/client/budgets/budgetsSlice';
type BudgetNameProps = {
children?: ReactNode;
diff --git a/packages/desktop-client/src/global-events.ts b/packages/desktop-client/src/global-events.ts
index 441a859e204..7df01c00387 100644
--- a/packages/desktop-client/src/global-events.ts
+++ b/packages/desktop-client/src/global-events.ts
@@ -2,13 +2,13 @@
import {
addGenericErrorNotification,
addNotification,
- closeBudgetUI,
closeModal,
loadPrefs,
pushModal,
replaceModal,
} from 'loot-core/client/actions';
import { setAppState } from 'loot-core/client/app/appSlice';
+import { closeBudgetUI } from 'loot-core/client/budgets/budgetsSlice';
import {
getAccounts,
getCategories,
diff --git a/packages/desktop-client/src/index.tsx b/packages/desktop-client/src/index.tsx
index a06cb0ecf82..62e31794d6a 100644
--- a/packages/desktop-client/src/index.tsx
+++ b/packages/desktop-client/src/index.tsx
@@ -16,6 +16,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 budgetsSlice from 'loot-core/src/client/budgets/budgetsSlice';
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';
@@ -37,6 +38,7 @@ const boundActions = bindActionCreators(
...actions,
...accountsSlice.actions,
...appSlice.actions,
+ ...budgetsSlice.actions,
...queriesSlice.actions,
},
store.dispatch,
diff --git a/packages/loot-core/src/client/actions/backups.ts b/packages/loot-core/src/client/actions/backups.ts
index 51e5100a7b5..e38ee59b6d8 100644
--- a/packages/loot-core/src/client/actions/backups.ts
+++ b/packages/loot-core/src/client/actions/backups.ts
@@ -1,9 +1,8 @@
// @ts-strict-ignore
import { send } from '../../platform/client/fetch';
+import { closeBudget, loadBudget } from '../budgets/budgetsSlice';
import { type AppDispatch, type GetRootState } from '../store';
-import { closeBudget, loadBudget } from './budgets';
-
// Take in the budget id so that backups can be loaded when a budget
// isn't opened
export function loadBackup(budgetId, backupId) {
@@ -14,7 +13,7 @@ export function loadBackup(budgetId, backupId) {
}
await send('backup-load', { id: budgetId, backupId });
- await dispatch(loadBudget(budgetId));
+ await dispatch(loadBudget({ id: budgetId }));
};
}
diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts
deleted file mode 100644
index f95bf260e04..00000000000
--- a/packages/loot-core/src/client/actions/budgets.ts
+++ /dev/null
@@ -1,315 +0,0 @@
-// @ts-strict-ignore
-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 { closeModal, pushModal } from './modals';
-import { loadPrefs, loadGlobalPrefs } from './prefs';
-
-export function loadBudgets() {
- return async (dispatch: AppDispatch) => {
- const budgets = await send('get-budgets');
-
- dispatch({
- type: constants.SET_BUDGETS,
- budgets,
- });
- };
-}
-
-export function loadRemoteFiles() {
- return async (dispatch: AppDispatch) => {
- const files = await send('get-remote-files');
-
- dispatch({
- type: constants.SET_REMOTE_FILES,
- files,
- });
- };
-}
-
-export function loadAllFiles() {
- return async (dispatch: AppDispatch, getState: GetRootState) => {
- const budgets = await send('get-budgets');
- const files = await send('get-remote-files');
-
- dispatch({
- type: constants.SET_ALL_FILES,
- budgets,
- remoteFiles: files,
- });
-
- return getState().budgets.allFiles;
- };
-}
-
-export function loadBudget(id: string, options = {}) {
- return async (dispatch: AppDispatch) => {
- dispatch(setAppState({ loadingText: t('Loading...') }));
-
- // Loading a budget may fail
- const { error } = await send('load-budget', { id, ...options });
-
- if (error) {
- const message = getSyncError(error, id);
- if (error === 'out-of-sync-migrations') {
- dispatch(pushModal('out-of-sync-migrations'));
- } else if (error === 'out-of-sync-data') {
- // confirm is not available on iOS
- if (typeof window.confirm !== 'undefined') {
- const showBackups = window.confirm(
- message +
- ' ' +
- t(
- 'Make sure the app is up-to-date. Do you want to load a backup?',
- ),
- );
-
- if (showBackups) {
- dispatch(pushModal('load-backup', { budgetId: id }));
- }
- } else {
- alert(message + ' ' + t('Make sure the app is up-to-date.'));
- }
- } else {
- alert(message);
- }
- } else {
- dispatch(closeModal());
-
- await dispatch(loadPrefs());
- }
-
- dispatch(setAppState({ loadingText: null }));
- };
-}
-
-export function closeBudget() {
- return async (dispatch: AppDispatch, getState: GetRootState) => {
- const prefs = getState().prefs.local;
- if (prefs && prefs.id) {
- // This clears out all the app state so the user starts fresh
- dispatch({ type: constants.CLOSE_BUDGET });
-
- dispatch(setAppState({ loadingText: t('Closing...') }));
- await send('close-budget');
- dispatch(setAppState({ loadingText: null }));
- if (localStorage.getItem('SharedArrayBufferOverride')) {
- window.location.reload();
- }
- }
- };
-}
-
-export function closeBudgetUI() {
- return async (dispatch: AppDispatch, getState: GetRootState) => {
- const prefs = getState().prefs.local;
- if (prefs && prefs.id) {
- dispatch({ type: constants.CLOSE_BUDGET });
- }
- };
-}
-
-export function deleteBudget(id?: string, cloudFileId?: string) {
- return async (dispatch: AppDispatch) => {
- await send('delete-budget', { id, cloudFileId });
- await dispatch(loadAllFiles());
- };
-}
-
-export function createBudget({ testMode = false, demoMode = false } = {}) {
- return async (dispatch: AppDispatch) => {
- dispatch(
- setAppState({
- loadingText:
- testMode || demoMode ? t('Making demo...') : t('Creating budget...'),
- }),
- );
-
- if (demoMode) {
- await send('create-demo-budget');
- } else {
- await send('create-budget', { testMode });
- }
-
- dispatch(closeModal());
-
- await dispatch(loadAllFiles());
- await dispatch(loadPrefs());
-
- // Set the loadingText to null after we've loaded the budget prefs
- // so that the existing manager page doesn't flash
- dispatch(setAppState({ loadingText: null }));
- };
-}
-
-export function validateBudgetName(name: string): {
- valid: boolean;
- message?: string;
-} {
- return send('validate-budget-name', { name });
-}
-
-export function uniqueBudgetName(name: string): string {
- return send('unique-budget-name', { name });
-}
-
-export function duplicateBudget({
- id,
- cloudId,
- oldName,
- newName,
- managePage,
- loadBudget = 'none',
- cloudSync,
-}: {
- id?: string;
- cloudId?: string;
- oldName: string;
- newName: string;
- managePage?: boolean;
- loadBudget: 'none' | 'original' | 'copy';
- /**
- * cloudSync is used to determine if the duplicate budget
- * should be synced to the server
- */
- cloudSync?: boolean;
-}) {
- return async (dispatch: AppDispatch) => {
- try {
- dispatch(
- setAppState({
- loadingText: t('Duplicating: {{oldName}} -- to: {{newName}}', {
- oldName,
- newName,
- }),
- }),
- );
-
- await send('duplicate-budget', {
- id,
- cloudId,
- newName,
- cloudSync,
- open: loadBudget,
- });
-
- dispatch(closeModal());
-
- if (managePage) {
- await dispatch(loadAllFiles());
- }
- } catch (error) {
- console.error('Error duplicating budget:', error);
- throw error instanceof Error
- ? error
- : new Error('Error duplicating budget: ' + String(error));
- } finally {
- dispatch(setAppState({ loadingText: null }));
- }
- };
-}
-
-export function importBudget(
- filepath: string,
- type: Parameters[0]['type'],
-) {
- return async (dispatch: AppDispatch) => {
- const { error } = await send('import-budget', { filepath, type });
- if (error) {
- throw new Error(error);
- }
-
- dispatch(closeModal());
-
- await dispatch(loadPrefs());
- };
-}
-
-export function uploadBudget(id?: string) {
- return async (dispatch: AppDispatch) => {
- const { error } = await send('upload-budget', { id });
- if (error) {
- return { error };
- }
-
- await dispatch(loadAllFiles());
- return {};
- };
-}
-
-export function closeAndLoadBudget(fileId: string) {
- return async (dispatch: AppDispatch) => {
- await dispatch(closeBudget());
- await dispatch(loadBudget(fileId));
- };
-}
-
-export function closeAndDownloadBudget(cloudFileId: string) {
- return async (dispatch: AppDispatch) => {
- await dispatch(closeBudget());
- dispatch(downloadBudget(cloudFileId, { replace: true }));
- };
-}
-
-export function downloadBudget(cloudFileId: string, { replace = false } = {}) {
- return async (dispatch: AppDispatch) => {
- dispatch(
- setAppState({
- loadingText: t('Downloading...'),
- }),
- );
-
- const { id, error } = await send('download-budget', {
- fileId: cloudFileId,
- replace,
- });
-
- if (error) {
- if (error.reason === 'decrypt-failure') {
- const opts = {
- hasExistingKey: error.meta && error.meta.isMissingKey,
- cloudFileId,
- onSuccess: () => {
- dispatch(downloadBudget(cloudFileId, { replace }));
- },
- };
-
- dispatch(pushModal('fix-encryption-key', opts));
- dispatch(setAppState({ loadingText: null }));
- } else if (error.reason === 'file-exists') {
- alert(
- t(
- 'A file with id “{{id}}” already exists with the name “{{name}}”. ' +
- 'This file will be replaced. This probably happened because files were manually ' +
- 'moved around outside of Actual.',
- {
- id: error.meta.id,
- name: error.meta.name,
- },
- ),
- );
-
- return dispatch(downloadBudget(cloudFileId, { replace: true }));
- } else {
- dispatch(setAppState({ loadingText: null }));
- alert(getDownloadError(error));
- }
- return null;
- } else {
- await Promise.all([
- dispatch(loadGlobalPrefs()),
- dispatch(loadAllFiles()),
- dispatch(loadBudget(id)),
- ]);
- dispatch(setAppState({ loadingText: null }));
- }
-
- return id;
- };
-}
diff --git a/packages/loot-core/src/client/actions/index.ts b/packages/loot-core/src/client/actions/index.ts
index 5662edf4e84..2e8f656fd16 100644
--- a/packages/loot-core/src/client/actions/index.ts
+++ b/packages/loot-core/src/client/actions/index.ts
@@ -1,7 +1,6 @@
export * from './modals';
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/user.ts b/packages/loot-core/src/client/actions/user.ts
index 5136f6cf6a1..e368a145b5a 100644
--- a/packages/loot-core/src/client/actions/user.ts
+++ b/packages/loot-core/src/client/actions/user.ts
@@ -1,8 +1,8 @@
import { send } from '../../platform/client/fetch';
+import { closeBudget, loadAllFiles } from '../budgets/budgetsSlice';
import * as constants from '../constants';
import { type AppDispatch } from '../store';
-import { loadAllFiles, closeBudget } from './budgets';
import { loadGlobalPrefs } from './prefs';
export function getUserData() {
diff --git a/packages/loot-core/src/client/budgets/budgetsSlice.ts b/packages/loot-core/src/client/budgets/budgetsSlice.ts
new file mode 100644
index 00000000000..805d6a2221f
--- /dev/null
+++ b/packages/loot-core/src/client/budgets/budgetsSlice.ts
@@ -0,0 +1,585 @@
+import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+import { type AppDispatch, type RootState } from '../store';
+import { Budget } from 'loot-core/types/budget';
+import { File } from 'loot-core/types/file';
+import { RemoteFile } from 'loot-core/server/cloud-storage';
+import { send } from 'loot-core/platform/client/fetch';
+import { setAppState } from '../app/appSlice';
+import { t } from 'i18next';
+import { getDownloadError, getSyncError } from 'loot-core/shared/errors';
+import { closeModal, loadGlobalPrefs, loadPrefs, pushModal } from '../actions';
+import * as constants from '../constants';
+import { Handlers } from 'loot-core/types/handlers';
+
+const sliceName = 'budgets';
+
+const createAppAsyncThunk = createAsyncThunk.withTypes<{
+ state: RootState;
+ dispatch: AppDispatch;
+}>();
+
+export const loadBudgets = createAppAsyncThunk(
+ `${sliceName}/loadBudgets`,
+ async (
+ _,
+ { dispatch },
+ ) => {
+ const budgets = await send('get-budgets');
+
+ await dispatch(setBudgets({ budgets }));
+ },
+);
+
+export const loadRemoteFiles = createAppAsyncThunk(
+ `${sliceName}/loadRemoteFiles`,
+ async (
+ _,
+ { dispatch },
+ ) => {
+ const files = await send('get-remote-files');
+
+ await dispatch(setRemoteFiles({ remoteFiles: files }));
+ },
+);
+
+export const loadAllFiles = createAppAsyncThunk(
+ `${sliceName}/loadAllFiles`,
+ async (
+ _,
+ { dispatch, getState },
+ ) => {
+ const budgets = await send('get-budgets');
+ const files = await send('get-remote-files');
+
+ await dispatch(setAllFiles({ budgets, remoteFiles: files }));
+
+ return getState().budgets.allFiles;
+ },
+);
+
+type LoadBudgetPayload = {
+ id: string;
+ // TODO: Is this still needed?
+ options?: Record;
+}
+
+export const loadBudget = createAppAsyncThunk(
+ `${sliceName}/loadBudget`,
+ async (
+ { id, options = {} }: LoadBudgetPayload,
+ { dispatch },
+ ) => {
+ await dispatch(setAppState({ loadingText: t('Loading...') }));
+
+ // Loading a budget may fail
+ const { error } = await send('load-budget', { id, ...options });
+
+ if (error) {
+ const message = getSyncError(error, id);
+ if (error === 'out-of-sync-migrations') {
+ await dispatch(pushModal('out-of-sync-migrations'));
+ } else if (error === 'out-of-sync-data') {
+ // confirm is not available on iOS
+ if (typeof window.confirm !== 'undefined') {
+ const showBackups = window.confirm(
+ message +
+ ' ' +
+ t(
+ 'Make sure the app is up-to-date. Do you want to load a backup?',
+ ),
+ );
+
+ if (showBackups) {
+ await dispatch(pushModal('load-backup', { budgetId: id }));
+ }
+ } else {
+ alert(message + ' ' + t('Make sure the app is up-to-date.'));
+ }
+ } else {
+ alert(message);
+ }
+ } else {
+ await dispatch(closeModal());
+ await dispatch(loadPrefs());
+ }
+
+ await dispatch(setAppState({ loadingText: null }));
+ },
+);
+
+export const closeBudget = createAppAsyncThunk(
+ `${sliceName}/closeBudget`,
+ async (
+ _,
+ { dispatch, getState },
+ ) => {
+ const prefs = getState().prefs.local;
+ if (prefs && prefs.id) {
+ // This clears out all the app state so the user starts fresh
+ // TODO: Change to use an action once CLOSE_BUDGET is migrated to redux toolkit.
+ await dispatch({ type: constants.CLOSE_BUDGET });
+
+ await dispatch(setAppState({ loadingText: t('Closing...') }));
+ await send('close-budget');
+ await dispatch(setAppState({ loadingText: null }));
+ if (localStorage.getItem('SharedArrayBufferOverride')) {
+ window.location.reload();
+ }
+ }
+ },
+);
+
+export const closeBudgetUI = createAppAsyncThunk(
+ `${sliceName}/closeBudgetUI`,
+ async (
+ _,
+ { dispatch, getState },
+ ) => {
+ const prefs = getState().prefs.local;
+ if (prefs && prefs.id) {
+ // TODO: Change to use an action once CLOSE_BUDGET is migrated to redux toolkit.
+ await dispatch({ type: constants.CLOSE_BUDGET });
+ }
+ },
+);
+
+type DeleteBudgetPayload = {
+ id?: string;
+ cloudFileId?: string;
+};
+
+export const deleteBudget = createAppAsyncThunk(
+ `${sliceName}/deleteBudget`,
+ async (
+ { id, cloudFileId }: DeleteBudgetPayload,
+ { dispatch },
+ ) => {
+ await send('delete-budget', { id, cloudFileId });
+ await dispatch(loadAllFiles());
+ },
+);
+
+type CreateBudgetPayload = {
+ testMode?: boolean;
+ demoMode?: boolean;
+};
+
+export const createBudget = createAppAsyncThunk(
+ `${sliceName}/createBudget`,
+ async (
+ { testMode = false, demoMode = false }: CreateBudgetPayload,
+ { dispatch },
+ ) => {
+ await dispatch(
+ setAppState({
+ loadingText:
+ testMode || demoMode ? t('Making demo...') : t('Creating budget...'),
+ }),
+ );
+
+ if (demoMode) {
+ await send('create-demo-budget');
+ } else {
+ await send('create-budget', { testMode });
+ }
+
+ await dispatch(closeModal());
+
+ await dispatch(loadAllFiles());
+ await dispatch(loadPrefs());
+
+ // Set the loadingText to null after we've loaded the budget prefs
+ // so that the existing manager page doesn't flash
+ await dispatch(setAppState({ loadingText: null }));
+ },
+);
+
+type DuplicateBudgetPayload = {
+ id?: string;
+ cloudId?: string;
+ oldName: string;
+ newName: string;
+ managePage?: boolean;
+ loadBudget: 'none' | 'original' | 'copy';
+ /**
+ * cloudSync is used to determine if the duplicate budget
+ * should be synced to the server
+ */
+ cloudSync?: boolean;
+};
+
+export const duplicateBudget = createAppAsyncThunk(
+ `${sliceName}/duplicateBudget`,
+ async (
+ {
+ id,
+ cloudId,
+ oldName,
+ newName,
+ managePage,
+ loadBudget = 'none',
+ cloudSync,
+ }: DuplicateBudgetPayload,
+ { dispatch },
+ ) => {
+ try {
+ await dispatch(
+ setAppState({
+ loadingText: t('Duplicating: {{oldName}} -- to: {{newName}}', {
+ oldName,
+ newName,
+ }),
+ }),
+ );
+
+ await send('duplicate-budget', {
+ id,
+ cloudId,
+ newName,
+ cloudSync,
+ open: loadBudget,
+ });
+
+ await dispatch(closeModal());
+
+ if (managePage) {
+ await dispatch(loadAllFiles());
+ }
+ } catch (error) {
+ console.error('Error duplicating budget:', error);
+ throw error instanceof Error
+ ? error
+ : new Error('Error duplicating budget: ' + String(error));
+ } finally {
+ await dispatch(setAppState({ loadingText: null }));
+ }
+ },
+);
+
+type ImportBudgetPayload = {
+ filepath: string,
+ type: Parameters[0]['type'],
+};
+
+export const importBudget = createAppAsyncThunk(
+ `${sliceName}/importBudget`,
+ async (
+ { filepath, type }: ImportBudgetPayload,
+ { dispatch },
+ ) => {
+ const { error } = await send('import-budget', { filepath, type });
+ if (error) {
+ throw new Error(error);
+ }
+
+ await dispatch(closeModal());
+ await dispatch(loadPrefs());
+ },
+);
+
+type UploadBudgetPayload = {
+ id?: string;
+};
+
+export const uploadBudget = createAppAsyncThunk(
+ `${sliceName}/uploadBudget`,
+ async (
+ { id }: UploadBudgetPayload,
+ { dispatch },
+ ) => {
+ const { error } = await send('upload-budget', { id });
+ if (error) {
+ return { error };
+ }
+
+ await dispatch(loadAllFiles());
+ return {};
+ },
+);
+
+export const closeAndLoadBudget = createAppAsyncThunk(
+ `${sliceName}/closeAndLoadBudget`,
+ async (
+ { fileId }: { fileId: string },
+ { dispatch },
+ ) => {
+ await dispatch(closeBudget());
+ await dispatch(loadBudget({ id: fileId }));
+ },
+);
+
+type CloseAndDownloadBudgetPayload = {
+ cloudFileId: string;
+};
+
+export const closeAndDownloadBudget = createAppAsyncThunk(
+ `${sliceName}/closeAndDownloadBudget`,
+ async (
+ { cloudFileId }: CloseAndDownloadBudgetPayload,
+ { dispatch },
+ ) => {
+ await dispatch(closeBudget());
+ await dispatch(downloadBudget({ cloudFileId, replace: true }));
+ },
+);
+
+type DownloadBudgetPayload = {
+ cloudFileId: string;
+ replace?: boolean;
+};
+
+export const downloadBudget = createAppAsyncThunk(
+ `${sliceName}/downloadBudget`,
+ async (
+ { cloudFileId, replace = false }: DownloadBudgetPayload,
+ { dispatch },
+ ): Promise => {
+ await dispatch(
+ setAppState({
+ loadingText: t('Downloading...'),
+ }),
+ );
+
+ const { id, error } = await send('download-budget', {
+ fileId: cloudFileId,
+ replace,
+ });
+
+ if (error) {
+ if (error.reason === 'decrypt-failure') {
+ const opts = {
+ hasExistingKey: error.meta && error.meta.isMissingKey,
+ cloudFileId,
+ onSuccess: () => {
+ dispatch(downloadBudget({ cloudFileId, replace }));
+ },
+ };
+
+ await dispatch(pushModal('fix-encryption-key', opts));
+ await dispatch(setAppState({ loadingText: null }));
+ } else if (error.reason === 'file-exists') {
+ alert(
+ t(
+ 'A file with id “{{id}}” already exists with the name “{{name}}”. ' +
+ 'This file will be replaced. This probably happened because files were manually ' +
+ 'moved around outside of Actual.',
+ {
+ id: error.meta.id,
+ name: error.meta.name,
+ },
+ ),
+ );
+
+ return await dispatch(downloadBudget({ cloudFileId, replace: true })).unwrap();
+ } else {
+ await dispatch(setAppState({ loadingText: null }));
+ alert(getDownloadError(error));
+ }
+ return null;
+ } else {
+ await Promise.all([
+ dispatch(loadGlobalPrefs()),
+ dispatch(loadAllFiles()),
+ dispatch(loadBudget(id)),
+ ]);
+ await dispatch(setAppState({ loadingText: null }));
+ }
+
+ return id;
+ },
+);
+
+type BudgetsState = {
+ budgets: Budget[];
+ remoteFiles: RemoteFile[] | null;
+ allFiles: File[] | null;
+};
+
+const initialState: BudgetsState = {
+ budgets: [],
+ remoteFiles: null,
+ allFiles: null,
+};
+
+type SetBudgetsPayload = {
+ budgets: Budget[];
+}
+
+type SetRemoteFilesPayload = {
+ remoteFiles: RemoteFile[];
+}
+
+type SetAllFilesPayload = {
+ budgets: Budget[];
+ remoteFiles: RemoteFile[];
+}
+
+const budgetsSlice = createSlice({
+ name: 'budgets',
+ initialState,
+ reducers: {
+ setBudgets(state, action: PayloadAction) {
+ state.budgets = action.payload.budgets;
+ state.allFiles = reconcileFiles(action.payload.budgets, state.remoteFiles);
+ },
+ setRemoteFiles(state, action: PayloadAction) {
+ state.remoteFiles = action.payload.remoteFiles;
+ state.allFiles = reconcileFiles(state.budgets, action.payload.remoteFiles);
+ },
+ setAllFiles(state, action: PayloadAction) {
+ state.budgets = action.payload.budgets;
+ state.remoteFiles = action.payload.remoteFiles;
+ state.allFiles = reconcileFiles(action.payload.budgets, action.payload.remoteFiles);
+ },
+ signOut(state) {
+ state.allFiles = null;
+ },
+ },
+});
+
+export const { name, reducer, getInitialState } = budgetsSlice;
+
+export const actions = {
+ ...budgetsSlice.actions,
+ loadBudgets,
+ loadRemoteFiles,
+ loadAllFiles,
+ loadBudget,
+ closeBudget,
+ closeBudgetUI,
+ deleteBudget,
+ createBudget,
+ duplicateBudget,
+ importBudget,
+ uploadBudget,
+ closeAndLoadBudget,
+ closeAndDownloadBudget,
+ downloadBudget,
+};
+
+export const { setBudgets, setRemoteFiles, setAllFiles, signOut } = actions;
+
+function sortFiles(arr: File[]) {
+ arr.sort((x, y) => {
+ const name1 = x.name.toLowerCase();
+ const name2 = y.name.toLowerCase();
+ let i = name1 < name2 ? -1 : name1 > name2 ? 1 : 0;
+ if (i === 0) {
+ const xId = x.state === 'remote' ? x.cloudFileId : x.id;
+ const yId = x.state === 'remote' ? x.cloudFileId : x.id;
+ i = xId < yId ? -1 : xId > yId ? 1 : 0;
+ }
+ return i;
+ });
+ return arr;
+}
+
+// States of a file:
+// 1. local - Only local (not uploaded/synced)
+// 2. remote - Unavailable locally, available to download
+// 3. synced - Downloaded & synced
+// 4. detached - Downloaded but broken group id (reset sync state)
+// 5. broken - user shouldn't have access to this file
+// 6. unknown - user is offline so can't determine the status
+function reconcileFiles(
+ localFiles: Budget[],
+ remoteFiles: RemoteFile[] | null,
+): File[] {
+ const reconciled = new Set();
+
+ const files = localFiles.map((localFile): File & { deleted: boolean } => {
+ const { cloudFileId, groupId } = localFile;
+ if (cloudFileId && groupId) {
+ // This is the case where for some reason getting the files from
+ // the server failed. We don't want to scare the user, just show
+ // an unknown state and tell them it'll be OK once they come
+ // back online
+ if (remoteFiles == null) {
+ return {
+ ...localFile,
+ cloudFileId,
+ groupId,
+ deleted: false,
+ state: 'unknown',
+ hasKey: true,
+ owner: '',
+ };
+ }
+
+ const remote = remoteFiles.find(f => localFile.cloudFileId === f.fileId);
+ if (remote) {
+ // Mark reconciled
+ reconciled.add(remote.fileId);
+
+ if (remote.groupId === localFile.groupId) {
+ return {
+ ...localFile,
+ cloudFileId,
+ groupId,
+ name: remote.name,
+ deleted: remote.deleted,
+ encryptKeyId: remote.encryptKeyId,
+ hasKey: remote.hasKey,
+ state: 'synced',
+ owner: remote.owner,
+ usersWithAccess: remote.usersWithAccess,
+ };
+ } else {
+ return {
+ ...localFile,
+ cloudFileId,
+ groupId,
+ name: remote.name,
+ deleted: remote.deleted,
+ encryptKeyId: remote.encryptKeyId,
+ hasKey: remote.hasKey,
+ state: 'detached',
+ owner: remote.owner,
+ usersWithAccess: remote.usersWithAccess,
+ };
+ }
+ } else {
+ return {
+ ...localFile,
+ cloudFileId,
+ groupId,
+ deleted: false,
+ state: 'broken',
+ hasKey: true,
+ owner: '',
+ };
+ }
+ } else {
+ return { ...localFile, deleted: false, state: 'local', hasKey: true };
+ }
+ });
+
+ const sorted = sortFiles(
+ files
+ .concat(
+ (remoteFiles || [])
+ .filter(f => !reconciled.has(f.fileId))
+ .map(f => {
+ return {
+ cloudFileId: f.fileId,
+ groupId: f.groupId,
+ name: f.name,
+ deleted: f.deleted,
+ encryptKeyId: f.encryptKeyId,
+ hasKey: f.hasKey,
+ state: 'remote',
+ owner: f.owner,
+ usersWithAccess: f.usersWithAccess,
+ };
+ }),
+ )
+ .filter(f => !f.deleted),
+ );
+
+ // One last pass to list all the broken (unauthorized) files at the
+ // bottom
+ return sorted
+ .filter(f => f.state !== 'broken')
+ .concat(sorted.filter(f => f.state === 'broken'));
+}
diff --git a/packages/loot-core/src/client/reducers/budgets.ts b/packages/loot-core/src/client/reducers/budgets.ts
deleted file mode 100644
index fad8eb9e190..00000000000
--- a/packages/loot-core/src/client/reducers/budgets.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import type { RemoteFile } from '../../server/cloud-storage';
-import type { Budget } from '../../types/budget';
-import { type File } from '../../types/file';
-import * as constants from '../constants';
-import type { Action } from '../state-types';
-import type { BudgetsState } from '../state-types/budgets';
-
-function sortFiles(arr: File[]) {
- arr.sort((x, y) => {
- const name1 = x.name.toLowerCase();
- const name2 = y.name.toLowerCase();
- let i = name1 < name2 ? -1 : name1 > name2 ? 1 : 0;
- if (i === 0) {
- const xId = x.state === 'remote' ? x.cloudFileId : x.id;
- const yId = x.state === 'remote' ? x.cloudFileId : x.id;
- i = xId < yId ? -1 : xId > yId ? 1 : 0;
- }
- return i;
- });
- return arr;
-}
-
-// States of a file:
-// 1. local - Only local (not uploaded/synced)
-// 2. remote - Unavailable locally, available to download
-// 3. synced - Downloaded & synced
-// 4. detached - Downloaded but broken group id (reset sync state)
-// 5. broken - user shouldn't have access to this file
-// 6. unknown - user is offline so can't determine the status
-function reconcileFiles(
- localFiles: Budget[],
- remoteFiles: RemoteFile[] | null,
-): File[] {
- const reconciled = new Set();
-
- const files = localFiles.map((localFile): File & { deleted: boolean } => {
- const { cloudFileId, groupId } = localFile;
- if (cloudFileId && groupId) {
- // This is the case where for some reason getting the files from
- // the server failed. We don't want to scare the user, just show
- // an unknown state and tell them it'll be OK once they come
- // back online
- if (remoteFiles == null) {
- return {
- ...localFile,
- cloudFileId,
- groupId,
- deleted: false,
- state: 'unknown',
- hasKey: true,
- owner: '',
- };
- }
-
- const remote = remoteFiles.find(f => localFile.cloudFileId === f.fileId);
- if (remote) {
- // Mark reconciled
- reconciled.add(remote.fileId);
-
- if (remote.groupId === localFile.groupId) {
- return {
- ...localFile,
- cloudFileId,
- groupId,
- name: remote.name,
- deleted: remote.deleted,
- encryptKeyId: remote.encryptKeyId,
- hasKey: remote.hasKey,
- state: 'synced',
- owner: remote.owner,
- usersWithAccess: remote.usersWithAccess,
- };
- } else {
- return {
- ...localFile,
- cloudFileId,
- groupId,
- name: remote.name,
- deleted: remote.deleted,
- encryptKeyId: remote.encryptKeyId,
- hasKey: remote.hasKey,
- state: 'detached',
- owner: remote.owner,
- usersWithAccess: remote.usersWithAccess,
- };
- }
- } else {
- return {
- ...localFile,
- cloudFileId,
- groupId,
- deleted: false,
- state: 'broken',
- hasKey: true,
- owner: '',
- };
- }
- } else {
- return { ...localFile, deleted: false, state: 'local', hasKey: true };
- }
- });
-
- const sorted = sortFiles(
- files
- .concat(
- (remoteFiles || [])
- .filter(f => !reconciled.has(f.fileId))
- .map(f => {
- return {
- cloudFileId: f.fileId,
- groupId: f.groupId,
- name: f.name,
- deleted: f.deleted,
- encryptKeyId: f.encryptKeyId,
- hasKey: f.hasKey,
- state: 'remote',
- owner: f.owner,
- usersWithAccess: f.usersWithAccess,
- };
- }),
- )
- .filter(f => !f.deleted),
- );
-
- // One last pass to list all the broken (unauthorized) files at the
- // bottom
- return sorted
- .filter(f => f.state !== 'broken')
- .concat(sorted.filter(f => f.state === 'broken'));
-}
-
-export const initialState: BudgetsState = {
- budgets: [],
- remoteFiles: null,
- allFiles: null,
-};
-
-export function update(state = initialState, action: Action): BudgetsState {
- switch (action.type) {
- case constants.SET_BUDGETS:
- return {
- ...state,
- budgets: action.budgets,
- allFiles: reconcileFiles(action.budgets, state.remoteFiles),
- };
- case constants.SET_REMOTE_FILES:
- return {
- ...state,
- remoteFiles: action.files,
- allFiles: reconcileFiles(state.budgets, action.files),
- };
- case constants.SET_ALL_FILES:
- return {
- ...state,
- budgets: action.budgets,
- remoteFiles: action.remoteFiles,
- allFiles: reconcileFiles(action.budgets, action.remoteFiles),
- };
- case constants.SIGN_OUT:
- // If the user logs out, make sure to reset all the files
- return { ...state, allFiles: null };
- default:
- }
- return state;
-}
diff --git a/packages/loot-core/src/client/reducers/index.ts b/packages/loot-core/src/client/reducers/index.ts
index 3cdfc844992..aaf67868ce9 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 budgets } from './budgets';
import { update as modals } from './modals';
import { update as notifications } from './notifications';
import { update as prefs } from './prefs';
@@ -8,6 +7,5 @@ export const reducers = {
prefs,
modals,
notifications,
- budgets,
user,
};
diff --git a/packages/loot-core/src/client/reducers/modals.ts b/packages/loot-core/src/client/reducers/modals.ts
index bcf1db0e041..4dadc8b2024 100644
--- a/packages/loot-core/src/client/reducers/modals.ts
+++ b/packages/loot-core/src/client/reducers/modals.ts
@@ -9,11 +9,15 @@ export const initialState: ModalsState = {
type ModalsAction =
| Action
- // Temporary until we migrate to redux toolkit.
+ // TODO. Temporary until we migrate to redux toolkit.
| {
- type: 'app/setAppState';
- payload: { loadingText: string | null };
- };
+ type: 'app/setAppState';
+ payload: { loadingText: string | null };
+ }
+ // TODO: Temporary until we migrate to redux toolkit.
+ | {
+ type: 'budgets/signOut';
+ };
export function update(
state = initialState,
@@ -55,7 +59,7 @@ export function update(
...state,
modalStack: idx < 0 ? state.modalStack : state.modalStack.slice(0, idx),
};
- // Temporary until we migrate to redux toolkit.
+ // TODO: Temporary until we migrate to redux toolkit.
case 'app/setAppState':
if (action.payload.loadingText) {
return {
@@ -64,7 +68,8 @@ export function update(
};
}
break;
- case constants.SIGN_OUT:
+ // TODO: Temporary until we migrate to redux toolkit.
+ case 'budgets/signOut':
return initialState;
default:
}
diff --git a/packages/loot-core/src/client/shared-listeners.ts b/packages/loot-core/src/client/shared-listeners.ts
index 190362227a8..e6d9c74850d 100644
--- a/packages/loot-core/src/client/shared-listeners.ts
+++ b/packages/loot-core/src/client/shared-listeners.ts
@@ -5,17 +5,16 @@ import { listen, send } from '../platform/client/fetch';
import {
addNotification,
- closeAndDownloadBudget,
loadPrefs,
pushModal,
resetSync,
signOut,
sync,
- uploadBudget,
} from './actions';
import { getAccounts, getCategories, getPayees } from './queries/queriesSlice';
import type { Notification } from './state-types/notifications';
import { type AppStore } from './store';
+import { closeAndDownloadBudget, uploadBudget } from './budgets/budgetsSlice';
export function listenForSyncEvent(store: AppStore) {
let attemptedSyncRepair = false;
@@ -175,7 +174,7 @@ export function listenForSyncEvent(store: AppStore) {
button: {
title: t('Register'),
action: async () => {
- await store.dispatch(uploadBudget());
+ await store.dispatch(uploadBudget({}));
store.dispatch(sync());
store.dispatch(loadPrefs());
},
@@ -225,7 +224,9 @@ export function listenForSyncEvent(store: AppStore) {
id: 'needs-revert',
button: {
title: t('Revert'),
- action: () => store.dispatch(closeAndDownloadBudget(cloudFileId)),
+ action: () => {
+ store.dispatch(closeAndDownloadBudget({ cloudFileId }))
+ },
},
};
break;
diff --git a/packages/loot-core/src/client/state-types/budgets.d.ts b/packages/loot-core/src/client/state-types/budgets.d.ts
deleted file mode 100644
index caa53f81de7..00000000000
--- a/packages/loot-core/src/client/state-types/budgets.d.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { RemoteFile } from '../../server/cloud-storage';
-import type { Budget } from '../../types/budget';
-import type { File } from '../../types/file';
-import type * as constants from '../constants';
-
-export type BudgetsState = {
- budgets: Budget[];
- remoteFiles: RemoteFile[] | null;
- allFiles: File[] | null;
-};
-
-export type SetBudgetsAction = {
- type: typeof constants.SET_BUDGETS;
- budgets: Budget[];
-};
-
-export type SetRemoteFilesAction = {
- type: typeof constants.SET_REMOTE_FILES;
- files: RemoteFile[];
-};
-
-export type SetAllFilesAction = {
- type: typeof constants.SET_ALL_FILES;
- budgets: Budget[];
- remoteFiles: RemoteFile[];
-};
-
-export type SignOutAction = {
- type: typeof constants.SIGN_OUT;
-};
-
-export type BudgetsActions =
- | SetBudgetsAction
- | SetRemoteFilesAction
- | SetAllFilesAction
- | SignOutAction;
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 a7665c59a83..6cb70748c04 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 { BudgetsActions, BudgetsState } from './budgets';
import type { ModalsActions, ModalsState } from './modals';
import type { NotificationsActions, NotificationsState } from './notifications';
import type { PrefsActions, PrefsState } from './prefs';
@@ -11,7 +10,6 @@ export type CloseBudgetAction = {
};
export type Action =
- | BudgetsActions
| ModalsActions
| NotificationsActions
| PrefsActions
@@ -19,7 +17,6 @@ export type Action =
| CloseBudgetAction;
export type State = {
- budgets: BudgetsState;
modals: ModalsState;
notifications: NotificationsState;
prefs: PrefsState;
diff --git a/packages/loot-core/src/client/store/index.ts b/packages/loot-core/src/client/store/index.ts
index dfbda45d608..96e49f8765a 100644
--- a/packages/loot-core/src/client/store/index.ts
+++ b/packages/loot-core/src/client/store/index.ts
@@ -15,13 +15,17 @@ import {
getInitialState as getInitialAppState,
} from '../app/appSlice';
import * as constants from '../constants';
+import {
+ name as budgetsSliceName,
+ reducer as budgetsSliceReducer,
+ getInitialState as getInitialBudgetsState,
+} from '../budgets/budgetsSlice';
import {
name as queriesSliceName,
reducer as queriesSliceReducer,
getInitialState as getInitialQueriesState,
} from '../queries/queriesSlice';
import { reducers } from '../reducers';
-import { initialState as initialBudgetsState } from '../reducers/budgets';
import { initialState as initialModalsState } from '../reducers/modals';
import { initialState as initialNotificationsState } from '../reducers/notifications';
import { initialState as initialPrefsState } from '../reducers/prefs';
@@ -31,6 +35,7 @@ const appReducer = combineReducers({
...reducers,
[accountsSliceName]: accountsSliceReducer,
[appSliceName]: appSliceReducer,
+ [budgetsSliceName]: budgetsSliceReducer,
[queriesSliceName]: queriesSliceReducer,
});
const rootReducer: typeof appReducer = (state, action) => {
@@ -42,7 +47,7 @@ const rootReducer: typeof appReducer = (state, action) => {
modals: initialModalsState,
notifications: initialNotificationsState,
queries: getInitialQueriesState(),
- budgets: state?.budgets || initialBudgetsState,
+ budgets: state?.budgets || getInitialBudgetsState(),
user: state?.user || initialUserState,
prefs: {
local: initialPrefsState.local,
diff --git a/packages/loot-core/src/client/store/mock.ts b/packages/loot-core/src/client/store/mock.ts
index 7392949bbc1..0e961f7059c 100644
--- a/packages/loot-core/src/client/store/mock.ts
+++ b/packages/loot-core/src/client/store/mock.ts
@@ -8,6 +8,10 @@ import {
name as appSliceName,
reducer as appSliceReducer,
} from '../app/appSlice';
+import {
+ name as budgetsSliceName,
+ reducer as budgetsSliceReducer,
+} from '../budgets/budgetsSlice';
import {
name as queriesSliceName,
reducer as qeriesSliceReducer,
@@ -20,6 +24,7 @@ const appReducer = combineReducers({
...reducers,
[accountsSliceName]: accountsSliceReducer,
[appSliceName]: appSliceReducer,
+ [budgetsSliceName]: budgetsSliceReducer,
[queriesSliceName]: qeriesSliceReducer,
});