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