From 243703b2f70532ec1acbd3088dda879b5d07a5b3 Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Tue, 7 May 2024 15:45:57 -0700
Subject: [PATCH] [Mobile] Budget file quick switch (#2507)

* Update autocomplete types

* Remote optional type

* Improve SingleInputModal

* Fix lint error

* Mobile budget file quick switch mode

* Release notes

* Updates

* Fix typecheck error

* Fix paddings

* Update modal

* Fix lint error

* Display no budget files

* Fix remote files not showing
---
 .../desktop-client/src/components/Modals.tsx  |   5 +
 .../src/components/manager/BudgetList.jsx     | 226 ++++++++++++------
 .../src/components/mobile/budget/index.tsx    |   5 +
 .../src/components/modals/BudgetListModal.tsx |  43 ++++
 .../components/modals/BudgetPageMenuModal.tsx |  14 ++
 .../modals/ScheduledTransactionMenuModal.tsx  |   2 +-
 .../loot-core/src/client/actions/budgets.ts   |  12 +
 .../src/client/state-types/modals.d.ts        |   2 +
 upcoming-release-notes/2507.md                |   6 +
 9 files changed, 241 insertions(+), 74 deletions(-)
 create mode 100644 packages/desktop-client/src/components/modals/BudgetListModal.tsx
 create mode 100644 upcoming-release-notes/2507.md

diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index d1f462d3a..643ea9478 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -14,6 +14,7 @@ import { useSyncServerStatus } from '../hooks/useSyncServerStatus';
 import { ModalTitle } from './common/Modal';
 import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
 import { AccountMenuModal } from './modals/AccountMenuModal';
+import { BudgetListModal } from './modals/BudgetListModal';
 import { BudgetPageMenuModal } from './modals/BudgetPageMenuModal';
 import { CategoryAutocompleteModal } from './modals/CategoryAutocompleteModal';
 import { CategoryGroupMenuModal } from './modals/CategoryGroupMenuModal';
@@ -609,6 +610,7 @@ export function Modals() {
               modalProps={modalProps}
               onAddCategoryGroup={options.onAddCategoryGroup}
               onToggleHiddenCategories={options.onToggleHiddenCategories}
+              onSwitchBudgetFile={options.onSwitchBudgetFile}
               onSwitchBudgetType={options.onSwitchBudgetType}
             />
           );
@@ -643,6 +645,9 @@ export function Modals() {
             </NamespaceContext.Provider>
           );
 
+        case 'budget-list':
+          return <BudgetListModal key={name} modalProps={modalProps} />;
+
         default:
           console.error('Unknown modal:', name);
           return null;
diff --git a/packages/desktop-client/src/components/manager/BudgetList.jsx b/packages/desktop-client/src/components/manager/BudgetList.jsx
index 33cee3764..912a68fc4 100644
--- a/packages/desktop-client/src/components/manager/BudgetList.jsx
+++ b/packages/desktop-client/src/components/manager/BudgetList.jsx
@@ -2,6 +2,8 @@ import React, { useState, useRef } from 'react';
 import { useDispatch, useSelector } from 'react-redux';
 
 import {
+  closeAndDownloadBudget,
+  closeAndLoadBudget,
   createBudget,
   downloadBudget,
   getUserData,
@@ -11,6 +13,8 @@ import {
 } from 'loot-core/client/actions';
 import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
 
+import { useInitialMount } from '../../hooks/useInitialMount';
+import { useLocalPref } from '../../hooks/useLocalPref';
 import { AnimatedLoading } from '../../icons/AnimatedLoading';
 import {
   SvgCloudCheck,
@@ -59,11 +63,27 @@ function FileMenu({ onDelete, onClose }) {
   }
 
   const items = [{ name: 'delete', text: 'Delete' }];
+  const { isNarrowWidth } = useResponsive();
+
+  const defaultMenuItemStyle = isNarrowWidth
+    ? {
+        ...styles.mobileMenuItem,
+        color: theme.menuItemText,
+        borderRadius: 0,
+        borderTop: `1px solid ${theme.pillBorder}`,
+      }
+    : {};
 
-  return <Menu onMenuSelect={onMenuSelect} items={items} />;
+  return (
+    <Menu
+      getItemStyle={() => defaultMenuItemStyle}
+      onMenuSelect={onMenuSelect}
+      items={items}
+    />
+  );
 }
 
-function DetailButton({ state, onDelete }) {
+function FileMenuButton({ state, onDelete }) {
   const [menuOpen, setMenuOpen] = useState(false);
 
   return (
@@ -143,7 +163,7 @@ function FileState({ file }) {
   );
 }
 
-function File({ file, onSelect, onDelete }) {
+function File({ file, quickSwitchMode, onSelect, onDelete }) {
   const selecting = useRef(false);
 
   async function _onSelect(file) {
@@ -198,13 +218,15 @@ function File({ file, onSelect, onDelete }) {
           />
         )}
 
-        <DetailButton state={file.state} onDelete={() => onDelete(file)} />
+        {!quickSwitchMode && (
+          <FileMenuButton state={file.state} onDelete={() => onDelete(file)} />
+        )}
       </View>
     </View>
   );
 }
 
-function BudgetTable({ files, onSelect, onDelete }) {
+function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
   return (
     <View
       style={{
@@ -217,19 +239,32 @@ function BudgetTable({ files, onSelect, onDelete }) {
         '& *': { userSelect: 'none' },
       }}
     >
-      {files.map(file => (
-        <File
-          key={file.id || file.cloudFileId}
-          file={file}
-          onSelect={onSelect}
-          onDelete={onDelete}
-        />
-      ))}
+      {!files || files.length === 0 ? (
+        <Text
+          style={{
+            ...styles.mediumText,
+            textAlign: 'center',
+            color: theme.pageTextSubdued,
+          }}
+        >
+          No budget files
+        </Text>
+      ) : (
+        files.map(file => (
+          <File
+            key={file.id || file.cloudFileId}
+            file={file}
+            quickSwitchMode={quickSwitchMode}
+            onSelect={onSelect}
+            onDelete={onDelete}
+          />
+        ))
+      )}
     </View>
   );
 }
 
-function RefreshButton({ onRefresh }) {
+function RefreshButton({ style, onRefresh }) {
   const [loading, setLoading] = useState(false);
 
   async function _onRefresh() {
@@ -244,7 +279,7 @@ function RefreshButton({ onRefresh }) {
     <Button
       type="bare"
       aria-label="Refresh"
-      style={{ padding: 10, marginRight: 5 }}
+      style={{ padding: 10, ...style }}
       onClick={_onRefresh}
     >
       <Icon style={{ width: 18, height: 18 }} />
@@ -252,9 +287,34 @@ function RefreshButton({ onRefresh }) {
   );
 }
 
-export function BudgetList() {
-  const files = useSelector(state => state.budgets.allFiles || []);
+function BudgetListHeader({ quickSwitchMode, onRefresh }) {
+  return (
+    <View
+      style={{
+        flexDirection: 'row',
+        justifyContent: 'space-between',
+        margin: 20,
+      }}
+    >
+      <Text
+        style={{
+          ...styles.veryLargeText,
+        }}
+      >
+        Files
+      </Text>
+      {!quickSwitchMode && <RefreshButton onRefresh={onRefresh} />}
+    </View>
+  );
+}
+
+export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
   const dispatch = useDispatch();
+  const allFiles = useSelector(state => state.budgets.allFiles || []);
+  const [id] = useLocalPref('id');
+
+  const files = id ? allFiles.filter(f => f.id !== id) : allFiles;
+
   const [creating, setCreating] = useState(false);
   const { isNarrowWidth } = useResponsive();
   const narrowButtonStyle = isNarrowWidth
@@ -270,87 +330,107 @@ export function BudgetList() {
     }
   };
 
+  const refresh = () => {
+    dispatch(getUserData());
+    dispatch(loadAllFiles());
+  };
+
+  const initialMount = useInitialMount();
+  if (initialMount && quickSwitchMode) {
+    refresh();
+  }
+
   return (
     <View
       style={{
         flex: 1,
         justifyContent: 'center',
-        marginInline: -20,
-        marginTop: 20,
-        width: '100vw',
+        ...(!quickSwitchMode && {
+          marginTop: 20,
+          width: '100vw',
+        }),
         [`@media (min-width: ${tokens.breakpoint_small})`]: {
           maxWidth: tokens.breakpoint_small,
           width: '100%',
         },
       }}
     >
-      <View>
-        <Text style={{ ...styles.veryLargeText, margin: 20 }}>Files</Text>
-        <View
-          style={{
-            position: 'absolute',
-            right: 0,
-            top: 0,
-            bottom: 0,
-            justifyContent: 'center',
-            marginRight: 5,
-          }}
-        >
-          <RefreshButton
-            onRefresh={() => {
-              dispatch(getUserData());
-              dispatch(loadAllFiles());
-            }}
-          />
-        </View>
-      </View>
-      <BudgetTable
+      {showHeader && (
+        <BudgetListHeader
+          quickSwitchMode={quickSwitchMode}
+          onRefresh={refresh}
+        />
+      )}
+      <BudgetFiles
         files={files}
+        quickSwitchMode={quickSwitchMode}
         onSelect={file => {
-          if (file.state === 'remote') {
-            dispatch(downloadBudget(file.cloudFileId));
-          } else {
-            dispatch(loadBudget(file.id, `Loading ${file.name}...`));
+          if (!id) {
+            if (file.state === 'remote') {
+              dispatch(downloadBudget(file.cloudFileId));
+            } else {
+              dispatch(loadBudget(file.id));
+            }
+          } else if (file.id !== id) {
+            if (file.state === 'remote') {
+              dispatch(closeAndDownloadBudget(file.cloudFileId));
+            } else {
+              dispatch(closeAndLoadBudget(file.id));
+            }
           }
         }}
         onDelete={file => dispatch(pushModal('delete-budget', { file }))}
       />
-      <View
-        style={{
-          flexDirection: 'row',
-          justifyContent: 'flex-end',
-          padding: 25,
-          gap: 15,
-        }}
-      >
-        <Button
-          type="bare"
+      {!quickSwitchMode && (
+        <View
           style={{
-            ...narrowButtonStyle,
-            color: theme.pageTextLight,
-          }}
-          onClick={() => {
-            dispatch(pushModal('import'));
+            flexDirection: 'row',
+            justifyContent: 'flex-end',
+            alignItems: 'center',
+            padding: 25,
           }}
         >
-          Import file
-        </Button>
-
-        <Button type="primary" onClick={onCreate} style={narrowButtonStyle}>
-          Create new file
-        </Button>
+          <Button
+            type="bare"
+            style={{
+              ...narrowButtonStyle,
+              marginLeft: 10,
+              color: theme.pageTextLight,
+            }}
+            onClick={e => {
+              e.preventDefault();
+              dispatch(pushModal('import'));
+            }}
+          >
+            Import file
+          </Button>
 
-        {isNonProductionEnvironment() && (
           <Button
             type="primary"
-            isSubmit={false}
-            onClick={() => onCreate({ testMode: true })}
-            style={narrowButtonStyle}
+            onClick={onCreate}
+            style={{
+              ...narrowButtonStyle,
+              marginLeft: 10,
+            }}
           >
-            Create test file
+            Create new file
           </Button>
-        )}
-      </View>
+
+          {isNonProductionEnvironment() && (
+            <Button
+              type="primary"
+              isSubmit={false}
+              onClick={() => onCreate({ testMode: true })}
+              style={{
+                ...narrowButtonStyle,
+                marginLeft: 10,
+              }}
+            >
+              Create test file
+            </Button>
+          )}
+        </View>
+      )}
     </View>
   );
 }
diff --git a/packages/desktop-client/src/components/mobile/budget/index.tsx b/packages/desktop-client/src/components/mobile/budget/index.tsx
index f7556d916..2cd7a522a 100644
--- a/packages/desktop-client/src/components/mobile/budget/index.tsx
+++ b/packages/desktop-client/src/components/mobile/budget/index.tsx
@@ -388,6 +388,10 @@ function BudgetInner(props: BudgetInnerProps) {
     );
   };
 
+  const onSwitchBudgetFile = () => {
+    dispatch(pushModal('budget-list'));
+  };
+
   const onOpenBudgetMonthMenu = month => {
     dispatch(
       pushModal(`${budgetType}-budget-month-menu`, {
@@ -403,6 +407,7 @@ function BudgetInner(props: BudgetInnerProps) {
       pushModal('budget-page-menu', {
         onAddCategoryGroup: onOpenNewCategoryGroupModal,
         onToggleHiddenCategories,
+        onSwitchBudgetFile,
         onSwitchBudgetType: onOpenSwitchBudgetTypeModal,
       }),
     );
diff --git a/packages/desktop-client/src/components/modals/BudgetListModal.tsx b/packages/desktop-client/src/components/modals/BudgetListModal.tsx
new file mode 100644
index 000000000..5d5906dfe
--- /dev/null
+++ b/packages/desktop-client/src/components/modals/BudgetListModal.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+
+import { useLocalPref } from '../../hooks/useLocalPref';
+import { Modal } from '../common/Modal';
+import { Text } from '../common/Text';
+import { View } from '../common/View';
+import { BudgetList } from '../manager/BudgetList';
+import { type CommonModalProps } from '../Modals';
+
+type BudgetListModalProps = {
+  modalProps: CommonModalProps;
+};
+
+export function BudgetListModal({ modalProps }: BudgetListModalProps) {
+  const [id] = useLocalPref('id');
+  const currentFile = useSelector(state =>
+    state.budgets.allFiles?.find(f => 'id' in f && f.id === id),
+  );
+
+  return (
+    <Modal
+      title="Switch Budget File"
+      showHeader
+      focusAfterClose={false}
+      {...modalProps}
+    >
+      <View
+        style={{
+          justifyContent: 'center',
+          alignItems: 'center',
+          margin: '20px 0',
+        }}
+      >
+        <Text style={{ fontSize: 17, fontWeight: 400 }}>Switching from:</Text>
+        <Text style={{ fontSize: 17, fontWeight: 700 }}>
+          {currentFile?.name}
+        </Text>
+      </View>
+      <BudgetList showHeader={false} quickSwitchMode={true} />
+    </Modal>
+  );
+}
diff --git a/packages/desktop-client/src/components/modals/BudgetPageMenuModal.tsx b/packages/desktop-client/src/components/modals/BudgetPageMenuModal.tsx
index 6c5a7f77a..2e3d06efb 100644
--- a/packages/desktop-client/src/components/modals/BudgetPageMenuModal.tsx
+++ b/packages/desktop-client/src/components/modals/BudgetPageMenuModal.tsx
@@ -17,6 +17,7 @@ export function BudgetPageMenuModal({
   modalProps,
   onAddCategoryGroup,
   onToggleHiddenCategories,
+  onSwitchBudgetFile,
   onSwitchBudgetType,
 }: BudgetPageMenuModalProps) {
   const defaultMenuItemStyle: CSSProperties = {
@@ -32,6 +33,7 @@ export function BudgetPageMenuModal({
         getItemStyle={() => defaultMenuItemStyle}
         onAddCategoryGroup={onAddCategoryGroup}
         onToggleHiddenCategories={onToggleHiddenCategories}
+        onSwitchBudgetFile={onSwitchBudgetFile}
         onSwitchBudgetType={onSwitchBudgetType}
       />
     </Modal>
@@ -44,12 +46,14 @@ type BudgetPageMenuProps = Omit<
 > & {
   onAddCategoryGroup: () => void;
   onToggleHiddenCategories: () => void;
+  onSwitchBudgetFile: () => void;
   onSwitchBudgetType: () => void;
 };
 
 function BudgetPageMenu({
   onAddCategoryGroup,
   onToggleHiddenCategories,
+  onSwitchBudgetFile,
   onSwitchBudgetType,
   ...props
 }: BudgetPageMenuProps) {
@@ -61,9 +65,15 @@ function BudgetPageMenu({
       case 'add-category-group':
         onAddCategoryGroup?.();
         break;
+      // case 'edit-mode':
+      //   onEditMode?.(true);
+      //   break;
       case 'toggle-hidden-categories':
         onToggleHiddenCategories?.();
         break;
+      case 'switch-budget-file':
+        onSwitchBudgetFile?.();
+        break;
       case 'switch-budget-type':
         onSwitchBudgetType?.();
         break;
@@ -85,6 +95,10 @@ function BudgetPageMenu({
           name: 'toggle-hidden-categories',
           text: `${!showHiddenCategories ? 'Show' : 'Hide'} hidden categories`,
         },
+        {
+          name: 'switch-budget-file',
+          text: 'Switch budget file',
+        },
         ...(isReportBudgetEnabled
           ? [
               {
diff --git a/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx b/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx
index b0857d863..23692dff3 100644
--- a/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx
+++ b/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx
@@ -37,7 +37,7 @@ export function ScheduledTransactionMenuModal({
   const schedule = scheduleData?.schedules?.[0];
 
   if (!schedule) {
-    return;
+    return null;
   }
 
   return (
diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts
index 9313d441b..c663a2934 100644
--- a/packages/loot-core/src/client/actions/budgets.ts
+++ b/packages/loot-core/src/client/actions/budgets.ts
@@ -172,6 +172,18 @@ export function uploadBudget(id: string) {
   };
 }
 
+export function closeAndLoadBudget(fileId: string) {
+  return async (dispatch: Dispatch) => {
+    // It's very important that we set this loading message before
+    // closing the budget. Otherwise, the manager will ignore our
+    // loading message and clear it when it loads, showing the file
+    // list which we don't want
+    dispatch(setAppState({ loadingText: 'Loading...' }));
+    await dispatch(closeBudget());
+    dispatch(loadBudget(fileId));
+  };
+}
+
 export function closeAndDownloadBudget(cloudFileId: string) {
   return async (dispatch: Dispatch) => {
     // It's very important that we set this loading message before
diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts
index 648e4b3d9..f3112e489 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -233,6 +233,7 @@ type FinanceModals = {
   'budget-page-menu': {
     onAddCategoryGroup: () => void;
     onToggleHiddenCategories: () => void;
+    onSwitchBudgetFile: () => void;
     onSwitchBudgetType: () => void;
   };
   'rollover-budget-month-menu': {
@@ -245,6 +246,7 @@ type FinanceModals = {
     onBudgetAction: (month: string, action: string, arg?: unknown) => void;
     onEditNotes: (month: string) => void;
   };
+  'budget-list';
 };
 
 export type PushModalAction = {
diff --git a/upcoming-release-notes/2507.md b/upcoming-release-notes/2507.md
new file mode 100644
index 000000000..788dbf59a
--- /dev/null
+++ b/upcoming-release-notes/2507.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [joel-jeremy]
+---
+
+Quickly switch to another budget file from the mobile budget page.
-- 
GitLab