From d548b76280415a5b8daa3c180b6b43fedd96da19 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 7 Jan 2025 23:13:25 -0800 Subject: [PATCH] Budgets slice --- .../desktop-client/src/components/App.tsx | 5 +- .../src/components/LoggedInUser.tsx | 3 +- .../src/components/manager/BudgetList.tsx | 15 +- .../src/components/manager/ConfigServer.tsx | 3 +- .../src/components/manager/WelcomeScreen.tsx | 5 +- .../manager/subscribe/Bootstrap.tsx | 2 +- .../modals/CreateEncryptionKeyModal.tsx | 3 +- .../components/modals/OpenIDEnableModal.tsx | 3 +- .../modals/OutOfSyncMigrationsModal.tsx | 3 +- .../components/modals/PasswordEnableModal.tsx | 3 +- .../components/modals/TransferOwnership.tsx | 4 +- .../modals/manager/DeleteFileModal.tsx | 12 +- .../modals/manager/DuplicateFileModal.tsx | 20 +- .../modals/manager/FilesSettingsModal.tsx | 3 +- .../modals/manager/ImportActualModal.tsx | 5 +- .../modals/manager/ImportYNAB4Modal.tsx | 5 +- .../modals/manager/ImportYNAB5Modal.tsx | 5 +- .../src/components/settings/index.tsx | 3 +- .../src/components/sidebar/BudgetName.tsx | 2 +- packages/desktop-client/src/global-events.ts | 2 +- packages/desktop-client/src/index.tsx | 2 + .../loot-core/src/client/actions/backups.ts | 5 +- .../loot-core/src/client/actions/budgets.ts | 315 ---------- .../loot-core/src/client/actions/index.ts | 1 - packages/loot-core/src/client/actions/user.ts | 2 +- .../src/client/budgets/budgetsSlice.ts | 585 ++++++++++++++++++ .../loot-core/src/client/reducers/budgets.ts | 165 ----- .../loot-core/src/client/reducers/index.ts | 2 - .../loot-core/src/client/reducers/modals.ts | 17 +- .../loot-core/src/client/shared-listeners.ts | 9 +- .../src/client/state-types/budgets.d.ts | 36 -- .../src/client/state-types/index.d.ts | 3 - packages/loot-core/src/client/store/index.ts | 9 +- packages/loot-core/src/client/store/mock.ts | 5 + 34 files changed, 674 insertions(+), 588 deletions(-) delete mode 100644 packages/loot-core/src/client/actions/budgets.ts create mode 100644 packages/loot-core/src/client/budgets/budgetsSlice.ts delete mode 100644 packages/loot-core/src/client/reducers/budgets.ts delete mode 100644 packages/loot-core/src/client/state-types/budgets.d.ts 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, });