diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 9ade60b0ff71de10b1c1c88f3e84ae9ed71f2eea..9e4d286684f950a85baed2c317768336905dc5ca 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -65,6 +65,7 @@ "sass": "^1.70.0", "swc-loader": "^0.2.3", "terser-webpack-plugin": "^5.3.10", + "usehooks-ts": "^3.0.1", "uuid": "^9.0.1", "vite": "^5.0.12", "vite-plugin-pwa": "^0.19.0", diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 8024afe7957e39cffa03a62aca42f5d0105b47d8..1dbf53dacd5fa6d22630c731c1bf4edec85dec7b 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -33,6 +33,7 @@ import { View } from './common/View'; import { GlobalKeys } from './GlobalKeys'; import { ManageRulesPage } from './ManageRulesPage'; import { MobileNavTabs } from './mobile/MobileNavTabs'; +import { TransactionEdit } from './mobile/transactions/TransactionEdit'; import { Modals } from './Modals'; import { Notifications } from './Notifications'; import { ManagePayeesPage } from './payees/ManagePayeesPage'; @@ -43,7 +44,6 @@ import { Settings } from './settings'; import { FloatableSidebar } from './sidebar'; import { SidebarProvider } from './sidebar/SidebarProvider'; import { Titlebar, TitlebarProvider } from './Titlebar'; -import { TransactionEdit } from './transactions/MobileTransaction'; function NarrowNotSupported({ redirectTo = '/budget', diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index a0290d431ab3b154338e73b6651e873b8737d144..8d6923cb414756c164a0f0c31d5762b266a0d534 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -34,15 +34,15 @@ import { useFailedAccounts } from '../../hooks/useFailedAccounts'; import { useLocalPref } from '../../hooks/useLocalPref'; import { usePayees } from '../../hooks/usePayees'; import { SelectedProviderWithItems } from '../../hooks/useSelected'; +import { + SplitsExpandedProvider, + useSplitsExpanded, +} from '../../hooks/useSplitsExpanded'; import { styles, theme } from '../../style'; import { Button } from '../common/Button'; import { Text } from '../common/Text'; import { View } from '../common/View'; import { TransactionList } from '../transactions/TransactionList'; -import { - SplitsExpandedProvider, - useSplitsExpanded, -} from '../transactions/TransactionsTable'; import { AccountHeader } from './Header'; diff --git a/packages/desktop-client/src/components/accounts/Balance.jsx b/packages/desktop-client/src/components/accounts/Balance.jsx index 5c0ca0272356d8aaf201c81a9f1994718909bd7d..72770444e9d3fecc64afc700748e35f6c7f60fd4 100644 --- a/packages/desktop-client/src/components/accounts/Balance.jsx +++ b/packages/desktop-client/src/components/accounts/Balance.jsx @@ -1,5 +1,6 @@ import React from 'react'; +import { isPreviewId } from 'loot-core/shared/transactions'; import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { q } from 'loot-core/src/shared/query'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; @@ -14,7 +15,6 @@ import { PrivacyFilter } from '../PrivacyFilter'; import { CellValue } from '../spreadsheet/CellValue'; import { useFormat } from '../spreadsheet/useFormat'; import { useSheetValue } from '../spreadsheet/useSheetValue'; -import { isPreviewId } from '../transactions/TransactionsTable'; function DetailedBalance({ name, balance, isExactBalance = true }) { const format = useFormat(); diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx index 3d968d19a920a849c8d3a995fa8ccaeb651fb6ce..078203fa662721fa8e9f8d483d235f4e295b6edb 100644 --- a/packages/desktop-client/src/components/accounts/Header.jsx +++ b/packages/desktop-client/src/components/accounts/Header.jsx @@ -1,6 +1,7 @@ import React, { useState, useRef } from 'react'; import { useLocalPref } from '../../hooks/useLocalPref'; +import { useSplitsExpanded } from '../../hooks/useSplitsExpanded'; import { useSyncServerStatus } from '../../hooks/useSyncServerStatus'; import { AnimatedLoading } from '../../icons/AnimatedLoading'; import { SvgAdd } from '../../icons/v1'; @@ -26,7 +27,6 @@ import { FiltersStack } from '../filters/FiltersStack'; import { KeyHandlers } from '../KeyHandlers'; import { NotesButton } from '../NotesButton'; import { SelectedTransactionsButton } from '../transactions/SelectedTransactions'; -import { useSplitsExpanded } from '../transactions/TransactionsTable'; import { Balances } from './Balance'; import { ReconcilingMessage, ReconcileTooltip } from './Reconcile'; diff --git a/packages/desktop-client/src/components/responsive/PullToRefresh.tsx b/packages/desktop-client/src/components/mobile/PullToRefresh.tsx similarity index 100% rename from packages/desktop-client/src/components/responsive/PullToRefresh.tsx rename to packages/desktop-client/src/components/mobile/PullToRefresh.tsx diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.jsx b/packages/desktop-client/src/components/mobile/accounts/Account.jsx similarity index 71% rename from packages/desktop-client/src/components/accounts/MobileAccount.jsx rename to packages/desktop-client/src/components/mobile/accounts/Account.jsx index 0b06e280e08fe92f3a69620660a239a40f5b0348..8a16a294c0e43cdf04bb96ea0ce8bbd308ea44e3 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccount.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/Account.jsx @@ -1,10 +1,9 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import debounce from 'debounce'; import memoizeOne from 'memoize-one'; -import { bindActionCreators } from 'redux'; +import { useDebounceCallback } from 'usehooks-ts'; import * as actions from 'loot-core/src/client/actions'; import { @@ -19,20 +18,20 @@ import { ungroupTransactions, } from 'loot-core/src/shared/transactions'; -import { useAccounts } from '../../hooks/useAccounts'; -import { useCategories } from '../../hooks/useCategories'; -import { useDateFormat } from '../../hooks/useDateFormat'; -import { useLocalPref } from '../../hooks/useLocalPref'; -import { useLocalPrefs } from '../../hooks/useLocalPrefs'; -import { useNavigate } from '../../hooks/useNavigate'; -import { usePayees } from '../../hooks/usePayees'; -import { useSetThemeColor } from '../../hooks/useSetThemeColor'; -import { theme, styles } from '../../style'; -import { Button } from '../common/Button'; -import { Text } from '../common/Text'; -import { View } from '../common/View'; - -import { AccountDetails } from './MobileAccountDetails'; +import { useAccounts } from '../../../hooks/useAccounts'; +import { useCategories } from '../../../hooks/useCategories'; +import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useLocalPrefs } from '../../../hooks/useLocalPrefs'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { usePayees } from '../../../hooks/usePayees'; +import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; +import { theme, styles } from '../../../style'; +import { Button } from '../../common/Button'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; + +import { AccountDetails } from './AccountDetails'; const getSchedulesTransform = memoizeOne((id, hasSearch) => { let filter = queries.getAccountFilter(id, '_account'); @@ -74,15 +73,13 @@ function PreviewTransactions({ children }) { ); } -let paged; - export function Account(props) { const accounts = useAccounts(); const payees = usePayees(); const navigate = useNavigate(); const [transactions, setTransactions] = useState([]); - const [searchText, setSearchText] = useState(''); + const [isSearching, setIsSearching] = useState(false); const [currentQuery, setCurrentQuery] = useState(); const newTransactions = useSelector(state => state.queries.newTransactions); @@ -100,32 +97,30 @@ export function Account(props) { }; const dispatch = useDispatch(); - const actionCreators = useMemo( - () => bindActionCreators(actions, dispatch), - [dispatch], - ); const { id: accountId } = useParams(); - const makeRootQuery = () => queries.makeTransactionsQuery(accountId); + const makeRootQuery = useCallback( + () => queries.makeTransactionsQuery(accountId), + [accountId], + ); - const updateQuery = query => { - if (paged) { - paged.unsubscribe(); - } + const paged = useRef(null); - paged = pagedQuery( + const updateQuery = useCallback(query => { + paged.current?.unsubscribe(); + paged.current = pagedQuery( query.options({ splits: 'grouped' }).select('*'), data => setTransactions(data), - { pageCount: 150, mapper: ungroupTransactions }, + { pageCount: 10, mapper: ungroupTransactions }, ); - }; + }, []); - const fetchTransactions = async () => { + const fetchTransactions = useCallback(async () => { const query = makeRootQuery(); setCurrentQuery(query); updateQuery(query); - }; + }, [makeRootQuery, updateQuery]); useEffect(() => { let unlisten; @@ -138,43 +133,49 @@ export function Account(props) { tables.includes('category_mapping') || tables.includes('payee_mapping') ) { - paged?.run(); + paged.current?.run(); } if (tables.includes('payees') || tables.includes('payee_mapping')) { - actionCreators.getPayees(); + dispatch(actions.getPayees()); } } }); await fetchTransactions(); - actionCreators.markAccountRead(accountId); + dispatch(actions.markAccountRead(accountId)); } setUpAccount(); return () => unlisten(); - }, []); + }, [accountId, dispatch, fetchTransactions]); // Load categories if necessary. const categories = useCategories(); - const updateSearchQuery = debounce(() => { - if (searchText === '' && currentQuery) { - updateQuery(currentQuery); - } else if (searchText && currentQuery) { - updateQuery( - queries.makeTransactionSearchQuery( - currentQuery, - searchText, - state.dateFormat, - ), - ); - } - }, 150); + const updateSearchQuery = useDebounceCallback( + useCallback( + searchText => { + if (searchText === '' && currentQuery) { + updateQuery(currentQuery); + } else if (searchText && currentQuery) { + updateQuery( + queries.makeTransactionSearchQuery( + currentQuery, + searchText, + dateFormat, + ), + ); + } - useEffect(updateSearchQuery, [searchText, currentQuery, state.dateFormat]); + setIsSearching(searchText !== ''); + }, + [currentQuery, dateFormat, updateQuery], + ), + 150, + ); useSetThemeColor(theme.mobileViewTheme); @@ -209,9 +210,8 @@ export function Account(props) { return state.newTransactions.includes(id); }; - const onSearch = async text => { - paged.unsubscribe(); - setSearchText(text); + const onSearch = text => { + updateSearchQuery(text); }; const onSelectTransaction = transaction => { @@ -227,7 +227,7 @@ export function Account(props) { return ( <SchedulesProvider - transform={getSchedulesTransform(accountId, searchText !== '')} + transform={getSchedulesTransform(accountId, isSearching)} > <PreviewTransactions accountId={props.accountId}> {prependTransactions => @@ -236,7 +236,6 @@ export function Account(props) { // This key forces the whole table rerender when the number // format changes {...state} - {...actionCreators} key={numberFormat + hideFraction} account={account} accounts={accounts} @@ -249,7 +248,7 @@ export function Account(props) { balanceUncleared={balanceUncleared} isNewTransaction={isNewTransaction} onLoadMore={() => { - paged?.fetchNext(); + paged.current?.fetchNext(); }} onSearch={onSearch} onSelectTransaction={onSelectTransaction} diff --git a/packages/desktop-client/src/components/accounts/MobileAccountDetails.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx similarity index 85% rename from packages/desktop-client/src/components/accounts/MobileAccountDetails.jsx rename to packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx index b1dbe957fb99de10f38fe2ee887b1ba19e63898a..0a48c0e01a8f33d0cb47171df94a2f41b44b61b5 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccountDetails.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx @@ -1,19 +1,21 @@ import React, { useState, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; -import { useActions } from '../../hooks/useActions'; -import { SvgAdd } from '../../icons/v1'; -import { SvgSearchAlternate } from '../../icons/v2'; -import { theme } from '../../style'; -import { ButtonLink } from '../common/ButtonLink'; -import { InputWithContent } from '../common/InputWithContent'; -import { Label } from '../common/Label'; -import { View } from '../common/View'; -import { MobileBackButton } from '../MobileBackButton'; -import { Page } from '../Page'; -import { PullToRefresh } from '../responsive/PullToRefresh'; -import { CellValue } from '../spreadsheet/CellValue'; -import { useSheetValue } from '../spreadsheet/useSheetValue'; -import { TransactionList } from '../transactions/MobileTransaction'; +import { syncAndDownload } from 'loot-core/client/actions'; + +import { SvgAdd } from '../../../icons/v1'; +import { SvgSearchAlternate } from '../../../icons/v2'; +import { theme } from '../../../style'; +import { ButtonLink } from '../../common/ButtonLink'; +import { InputWithContent } from '../../common/InputWithContent'; +import { Label } from '../../common/Label'; +import { View } from '../../common/View'; +import { MobileBackButton } from '../../MobileBackButton'; +import { Page } from '../../Page'; +import { CellValue } from '../../spreadsheet/CellValue'; +import { useSheetValue } from '../../spreadsheet/useSheetValue'; +import { PullToRefresh } from '../PullToRefresh'; +import { TransactionList } from '../transactions/TransactionList'; function TransactionSearchInput({ accountName, onSearch }) { const [text, setText] = useState(''); @@ -76,15 +78,14 @@ export function AccountDetails({ onLoadMore, onSearch, onSelectTransaction, - pushModal, }) { const allTransactions = useMemo(() => { return prependTransactions.concat(transactions); }, [prependTransactions, transactions]); - const { syncAndDownload } = useActions(); + const dispatch = useDispatch(); const onRefresh = async () => { - await syncAndDownload(account.id); + await dispatch(syncAndDownload(account.id)); }; return ( @@ -207,7 +208,6 @@ export function AccountDetails({ isNew={isNewTransaction} onLoadMore={onLoadMore} onSelect={onSelectTransaction} - pushModal={pushModal} /> </PullToRefresh> </Page> diff --git a/packages/desktop-client/src/components/accounts/MobileAccounts.jsx b/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx similarity index 90% rename from packages/desktop-client/src/components/accounts/MobileAccounts.jsx rename to packages/desktop-client/src/components/mobile/accounts/Accounts.jsx index c4ff2951204af54bcc02b55ed5f821428dd79fda..02df449b09372bbdc84e061a6376d4f7a5c5809c 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccounts.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx @@ -4,21 +4,21 @@ import { useDispatch, useSelector } from 'react-redux'; import { replaceModal, syncAndDownload } from 'loot-core/src/client/actions'; import * as queries from 'loot-core/src/client/queries'; -import { useAccounts } from '../../hooks/useAccounts'; -import { useCategories } from '../../hooks/useCategories'; -import { useLocalPref } from '../../hooks/useLocalPref'; -import { useNavigate } from '../../hooks/useNavigate'; -import { useSetThemeColor } from '../../hooks/useSetThemeColor'; -import { SvgAdd } from '../../icons/v1'; -import { theme, styles } from '../../style'; -import { Button } from '../common/Button'; -import { Text } from '../common/Text'; -import { TextOneLine } from '../common/TextOneLine'; -import { View } from '../common/View'; -import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs'; -import { Page } from '../Page'; -import { PullToRefresh } from '../responsive/PullToRefresh'; -import { CellValue } from '../spreadsheet/CellValue'; +import { useAccounts } from '../../../hooks/useAccounts'; +import { useCategories } from '../../../hooks/useCategories'; +import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; +import { SvgAdd } from '../../../icons/v1'; +import { theme, styles } from '../../../style'; +import { Button } from '../../common/Button'; +import { Text } from '../../common/Text'; +import { TextOneLine } from '../../common/TextOneLine'; +import { View } from '../../common/View'; +import { Page } from '../../Page'; +import { CellValue } from '../../spreadsheet/CellValue'; +import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs'; +import { PullToRefresh } from '../PullToRefresh'; function AccountHeader({ name, amount, style = {} }) { return ( diff --git a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx similarity index 96% rename from packages/desktop-client/src/components/budget/MobileBudgetTable.jsx rename to packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index 0aaf75da6f5b1410a770e80c3f21e9501a15eba4..92bdf7fd60203187defcf5b60cf993e17000f606 100644 --- a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -1,49 +1,51 @@ import React, { memo, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; import memoizeOne from 'memoize-one'; +import { pushModal } from 'loot-core/client/actions'; import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries'; import * as monthUtils from 'loot-core/src/shared/months'; -import { useFeatureFlag } from '../../hooks/useFeatureFlag'; -import { useLocalPref } from '../../hooks/useLocalPref'; +import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; +import { useLocalPref } from '../../../hooks/useLocalPref'; import { SingleActiveEditFormProvider, useSingleActiveEditForm, -} from '../../hooks/useSingleActiveEditForm'; +} from '../../../hooks/useSingleActiveEditForm'; import { SvgArrowThinLeft, SvgArrowThinRight, SvgDotsHorizontalTriple, -} from '../../icons/v1'; -import { useResponsive } from '../../ResponsiveProvider'; -import { theme, styles } from '../../style'; -import { Button } from '../common/Button'; -import { Card } from '../common/Card'; -import { Label } from '../common/Label'; -import { Menu } from '../common/Menu'; -import { Text } from '../common/Text'; -import { View } from '../common/View'; -import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs'; -import { Page } from '../Page'; -import { PullToRefresh } from '../responsive/PullToRefresh'; -import { CellValue } from '../spreadsheet/CellValue'; -import { NamespaceContext } from '../spreadsheet/NamespaceContext'; -import { useFormat } from '../spreadsheet/useFormat'; -import { useSheetValue } from '../spreadsheet/useSheetValue'; -import { Tooltip, useTooltip } from '../tooltips'; -import { AmountInput } from '../util/AmountInput'; +} from '../../../icons/v1'; +import { useResponsive } from '../../../ResponsiveProvider'; +import { theme, styles } from '../../../style'; +import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover'; +import { BalanceTooltip as ReportBudgetBalanceTooltip } from '../../budget/report/BalanceTooltip'; +import { BalanceTooltip as RolloverBudgetBalanceTooltip } from '../../budget/rollover/BalanceTooltip'; +import { makeAmountGrey } from '../../budget/util'; +import { Button } from '../../common/Button'; +import { Card } from '../../common/Card'; +import { Label } from '../../common/Label'; +import { Menu } from '../../common/Menu'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; +import { Page } from '../../Page'; +import { CellValue } from '../../spreadsheet/CellValue'; +import { NamespaceContext } from '../../spreadsheet/NamespaceContext'; +import { useFormat } from '../../spreadsheet/useFormat'; +import { useSheetValue } from '../../spreadsheet/useSheetValue'; +import { Tooltip, useTooltip } from '../../tooltips'; +import { AmountInput } from '../../util/AmountInput'; +import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs'; +import { PullToRefresh } from '../PullToRefresh'; // import { // AmountAccessoryContext, // MathOperations // } from '../mobile/AmountInput'; // import { DragDrop, Draggable, Droppable, DragDropHighlight } from './dragdrop'; -import { BalanceWithCarryover } from './BalanceWithCarryover'; -import { ListItem, ROW_HEIGHT } from './MobileTable'; -import { BalanceTooltip as ReportBudgetBalanceTooltip } from './report/BalanceTooltip'; -import { BalanceTooltip as RolloverBudgetBalanceTooltip } from './rollover/BalanceTooltip'; -import { makeAmountGrey } from './util'; +import { ListItem, ROW_HEIGHT } from './ListItem'; function ToBudget({ toBudget, onClick }) { const amount = useSheetValue(toBudget); @@ -1034,7 +1036,6 @@ function BudgetGroups({ showBudgetedCol, show3Cols, showHiddenCategories, - pushModal, }) { const separateGroups = memoizeOne(groups => { return { @@ -1073,7 +1074,6 @@ function BudgetGroups({ onBudgetAction={onBudgetAction} show3Cols={show3Cols} showHiddenCategories={showHiddenCategories} - pushModal={pushModal} /> ); })} @@ -1102,7 +1102,6 @@ function BudgetGroups({ onEditGroup={onEditGroup} onEditCategory={onEditCategory} onBudgetAction={onBudgetAction} - pushModal={pushModal} /> )} </View> @@ -1133,7 +1132,6 @@ export function BudgetTable({ onBudgetAction, onRefresh, onSwitchBudgetType, - pushModal, onEditGroup, onEditCategory, }) { @@ -1142,6 +1140,7 @@ export function BudgetTable({ // let editMode = false; // neuter editMode -- sorry, not rewriting drag-n-drop right now const format = useFormat(); + const dispatch = useDispatch(); const [showSpentColumn = false, setShowSpentColumnPref] = useLocalPref( 'mobile.showSpentColumn', @@ -1161,9 +1160,11 @@ export function BudgetTable({ }; const _onSwitchBudgetType = () => { - pushModal('switch-budget-type', { - onSwitch: onSwitchBudgetType, - }); + dispatch( + pushModal('switch-budget-type', { + onSwitch: onSwitchBudgetType, + }), + ); }; const onToggleHiddenCategories = () => { @@ -1378,7 +1379,6 @@ export function BudgetTable({ onReorderGroup={onReorderGroup} onOpenMonthActionMenu={onOpenMonthActionMenu} onBudgetAction={onBudgetAction} - pushModal={pushModal} /> </View> ) : ( @@ -1412,7 +1412,6 @@ export function BudgetTable({ onReorderGroup={onReorderGroup} onOpenMonthActionMenu={onOpenMonthActionMenu} onBudgetAction={onBudgetAction} - pushModal={pushModal} /> </View> diff --git a/packages/desktop-client/src/components/budget/MobileTable.tsx b/packages/desktop-client/src/components/mobile/budget/ListItem.tsx similarity index 86% rename from packages/desktop-client/src/components/budget/MobileTable.tsx rename to packages/desktop-client/src/components/mobile/budget/ListItem.tsx index 1b1ad6c88c5a53169f6a13f7bcc3412c9268a5f8..8eef9483dcee8f652956827d9a756b2dfdce049a 100644 --- a/packages/desktop-client/src/components/budget/MobileTable.tsx +++ b/packages/desktop-client/src/components/mobile/budget/ListItem.tsx @@ -1,7 +1,7 @@ import React, { type ComponentProps, type ReactNode } from 'react'; -import { type CSSProperties, theme } from '../../style'; -import { View } from '../common/View'; +import { type CSSProperties, theme } from '../../../style'; +import { View } from '../../common/View'; export const ROW_HEIGHT = 50; diff --git a/packages/desktop-client/src/components/budget/MobileBudget.tsx b/packages/desktop-client/src/components/mobile/budget/index.tsx similarity index 65% rename from packages/desktop-client/src/components/budget/MobileBudget.tsx rename to packages/desktop-client/src/components/mobile/budget/index.tsx index f5145aeec43312b06a2e428b5a8b1349a809a834..d221f00a50c8998c1eb41a3082d927774deb1b91 100644 --- a/packages/desktop-client/src/components/budget/MobileBudget.tsx +++ b/packages/desktop-client/src/components/mobile/budget/index.tsx @@ -1,6 +1,23 @@ // @ts-strict-ignore import React, { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + applyBudgetAction, + collapseModals, + createCategory, + createGroup, + deleteCategory, + deleteGroup, + getCategories, + moveCategory, + moveCategoryGroup, + pushModal, + updateCategory, + updateGroup, + sync, + loadPrefs, +} from 'loot-core/client/actions'; import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; @@ -9,59 +26,26 @@ import { type CategoryGroupEntity, } from 'loot-core/src/types/models'; -import { type BoundActions, useActions } from '../../hooks/useActions'; -import { useCategories } from '../../hooks/useCategories'; -import { useLocalPref } from '../../hooks/useLocalPref'; -import { useSetThemeColor } from '../../hooks/useSetThemeColor'; -import { AnimatedLoading } from '../../icons/AnimatedLoading'; -import { theme } from '../../style'; -import { View } from '../common/View'; -import { SyncRefresh } from '../SyncRefresh'; +import { useCategories } from '../../../hooks/useCategories'; +import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; +import { AnimatedLoading } from '../../../icons/AnimatedLoading'; +import { theme } from '../../../style'; +import { prewarmMonth, switchBudgetType } from '../../budget/util'; +import { View } from '../../common/View'; +import { SyncRefresh } from '../../SyncRefresh'; -import { BudgetTable } from './MobileBudgetTable'; -import { prewarmMonth, switchBudgetType } from './util'; +import { BudgetTable } from './BudgetTable'; type BudgetInnerProps = { categories: CategoryEntity[]; categoryGroups: CategoryGroupEntity[]; - loadPrefs: BoundActions['loadPrefs']; - savePrefs: BoundActions['savePrefs']; budgetType: 'rollover' | 'report'; spreadsheet: ReturnType<typeof useSpreadsheet>; - applyBudgetAction: BoundActions['applyBudgetAction']; - collapseModals: BoundActions['collapseModals']; - pushModal: BoundActions['pushModal']; - getCategories: BoundActions['getCategories']; - createCategory: BoundActions['createCategory']; - updateCategory: BoundActions['updateCategory']; - deleteCategory: BoundActions['deleteCategory']; - moveCategory: BoundActions['moveCategory']; - createGroup: BoundActions['createGroup']; - updateGroup: BoundActions['updateGroup']; - deleteGroup: BoundActions['deleteGroup']; - moveCategoryGroup: BoundActions['moveCategoryGroup']; - sync: BoundActions['sync']; }; function BudgetInner(props: BudgetInnerProps) { - const { - categoryGroups, - categories, - loadPrefs, - budgetType, - spreadsheet, - applyBudgetAction, - collapseModals, - pushModal, - createGroup, - updateGroup, - deleteGroup, - moveCategoryGroup, - createCategory, - updateCategory, - deleteCategory, - moveCategory, - } = props; + const { categoryGroups, categories, budgetType, spreadsheet } = props; const currMonth = monthUtils.currentMonth(); @@ -73,13 +57,14 @@ function BudgetInner(props: BudgetInnerProps) { const [_numberFormat] = useLocalPref('numberFormat'); const numberFormat = _numberFormat || 'comma-dot'; const [hideFraction = false] = useLocalPref('hideFraction'); + const dispatch = useDispatch(); useEffect(() => { async function init() { const { start, end } = await send('get-budget-bounds'); setBounds({ start, end }); - await prewarmMonth(props.budgetType, props.spreadsheet, currentMonth); + await prewarmMonth(budgetType, spreadsheet, currentMonth); setInitialized(true); } @@ -94,51 +79,59 @@ function BudgetInner(props: BudgetInnerProps) { tables.includes('category_groups')) ) { // TODO: is this loading every time? - props.getCategories(); + dispatch(getCategories()); } }); return () => unlisten(); - }, []); + }, [budgetType, currentMonth, dispatch, spreadsheet]); + + const onBudgetAction = async (month, type, args) => { + dispatch(applyBudgetAction(month, type, args)); + }; const onShowBudgetSummary = () => { if (budgetType === 'report') { - pushModal('report-budget-summary', { - month: currentMonth, - }); + dispatch( + pushModal('report-budget-summary', { + month: currentMonth, + }), + ); } else { - pushModal('rollover-budget-summary', { - month: currentMonth, - onBudgetAction: applyBudgetAction, - }); + dispatch( + pushModal('rollover-budget-summary', { + month: currentMonth, + onBudgetAction, + }), + ); } }; - // const onBudgetAction = type => { - // applyBudgetAction(currentMonth, type, bounds); - // }; - const onAddGroup = () => { - pushModal('new-category-group', { - onValidate: name => (!name ? 'Name is required.' : null), - onSubmit: async name => { - await createGroup(name); - }, - }); + dispatch( + pushModal('new-category-group', { + onValidate: name => (!name ? 'Name is required.' : null), + onSubmit: async name => { + dispatch(createGroup(name)); + }, + }), + ); }; const onAddCategory = (groupId, isIncome) => { - pushModal('new-category', { - onValidate: name => (!name ? 'Name is required.' : null), - onSubmit: async name => { - collapseModals('category-group-menu'); - await createCategory(name, groupId, isIncome, false); - }, - }); + dispatch( + pushModal('new-category', { + onValidate: name => (!name ? 'Name is required.' : null), + onSubmit: async name => { + dispatch(collapseModals('category-group-menu')); + dispatch(createCategory(name, groupId, isIncome, false)); + }, + }), + ); }; const onSaveGroup = group => { - updateGroup(group); + dispatch(updateGroup(group)); }; const onDeleteGroup = async groupId => { @@ -157,21 +150,23 @@ function BudgetInner(props: BudgetInnerProps) { } if (mustTransfer) { - pushModal('confirm-category-delete', { - group: groupId, - onDelete: transferCategory => { - collapseModals('category-group-menu'); - deleteGroup(groupId, transferCategory); - }, - }); + dispatch( + pushModal('confirm-category-delete', { + group: groupId, + onDelete: transferCategory => { + dispatch(collapseModals('category-group-menu')); + dispatch(deleteGroup(groupId, transferCategory)); + }, + }), + ); } else { - collapseModals('category-group-menu'); - deleteGroup(groupId); + dispatch(collapseModals('category-group-menu')); + dispatch(deleteGroup(groupId)); } }; const onSaveCategory = category => { - updateCategory(category); + dispatch(updateCategory(category)); }; const onDeleteCategory = async categoryId => { @@ -180,18 +175,20 @@ function BudgetInner(props: BudgetInnerProps) { }); if (mustTransfer) { - pushModal('confirm-category-delete', { - category: categoryId, - onDelete: transferCategory => { - if (categoryId !== transferCategory) { - collapseModals('category-menu'); - deleteCategory(categoryId, transferCategory); - } - }, - }); + dispatch( + pushModal('confirm-category-delete', { + category: categoryId, + onDelete: transferCategory => { + if (categoryId !== transferCategory) { + dispatch(collapseModals('category-menu')); + dispatch(deleteCategory(categoryId, transferCategory)); + } + }, + }), + ); } else { - collapseModals('category-menu'); - deleteCategory(categoryId); + dispatch(collapseModals('category-menu')); + dispatch(deleteCategory(categoryId)); } }; @@ -221,7 +218,7 @@ function BudgetInner(props: BudgetInnerProps) { targetId = catId; } - moveCategory(id, groupId, targetId); + dispatch(moveCategory(id, groupId, targetId)); }; const onReorderGroup = (id, targetId, position) => { @@ -231,17 +228,7 @@ function BudgetInner(props: BudgetInnerProps) { idx < categoryGroups.length - 1 ? categoryGroups[idx + 1].id : null; } - moveCategoryGroup(id, targetId); - }; - - const sync = async () => { - const result = await props.sync(); - if (result?.error) { - return 'error'; - } else if (result) { - return 'updated'; - } - return null; + dispatch(moveCategoryGroup(id, targetId)); }; const onPrevMonth = async () => { @@ -306,7 +293,9 @@ function BudgetInner(props: BudgetInnerProps) { spreadsheet, bounds, currentMonth, - () => loadPrefs(), + async () => { + dispatch(loadPrefs()); + }, ); setInitialized(true); @@ -318,41 +307,49 @@ function BudgetInner(props: BudgetInnerProps) { const onEditGroupNotes = id => { const group = categoryGroups.find(g => g.id === id); - pushModal('notes', { - id, - name: group.name, - onSave: onSaveNotes, - }); + dispatch( + pushModal('notes', { + id, + name: group.name, + onSave: onSaveNotes, + }), + ); }; const onEditCategoryNotes = id => { const category = categories.find(c => c.id === id); - pushModal('notes', { - id, - name: category.name, - onSave: onSaveNotes, - }); + dispatch( + pushModal('notes', { + id, + name: category.name, + onSave: onSaveNotes, + }), + ); }; const onEditGroup = id => { const group = categoryGroups.find(g => g.id === id); - pushModal('category-group-menu', { - groupId: group.id, - onSave: onSaveGroup, - onAddCategory, - onEditNotes: onEditGroupNotes, - onDelete: onDeleteGroup, - }); + dispatch( + pushModal('category-group-menu', { + groupId: group.id, + onSave: onSaveGroup, + onAddCategory, + onEditNotes: onEditGroupNotes, + onDelete: onDeleteGroup, + }), + ); }; const onEditCategory = id => { const category = categories.find(c => c.id === id); - pushModal('category-menu', { - categoryId: category.id, - onSave: onSaveCategory, - onEditNotes: onEditCategoryNotes, - onDelete: onDeleteCategory, - }); + dispatch( + pushModal('category-menu', { + categoryId: category.id, + onSave: onSaveCategory, + onEditNotes: onEditCategoryNotes, + onDelete: onDeleteCategory, + }), + ); }; if (!categoryGroups || !initialized) { @@ -374,7 +371,7 @@ function BudgetInner(props: BudgetInnerProps) { return ( <SyncRefresh onSync={async () => { - await sync(); + dispatch(sync()); }} > {({ onRefresh }) => ( @@ -400,10 +397,9 @@ function BudgetInner(props: BudgetInnerProps) { onReorderCategory={onReorderCategory} onReorderGroup={onReorderGroup} onOpenMonthActionMenu={() => {}} //onOpenMonthActionMenu} - onBudgetAction={applyBudgetAction} + onBudgetAction={onBudgetAction} onRefresh={onRefresh} onSwitchBudgetType={onSwitchBudgetType} - pushModal={pushModal} onEditGroup={onEditGroup} onEditCategory={onEditCategory} /> @@ -416,8 +412,6 @@ export function Budget() { const { list: categories, grouped: categoryGroups } = useCategories(); const [_budgetType] = useLocalPref('budgetType'); const budgetType = _budgetType || 'rollover'; - - const actions = useActions(); const spreadsheet = useSpreadsheet(); useSetThemeColor(theme.mobileViewTheme); return ( @@ -425,7 +419,6 @@ export function Budget() { categoryGroups={categoryGroups} categories={categories} budgetType={budgetType} - {...actions} spreadsheet={spreadsheet} /> ); diff --git a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx similarity index 90% rename from packages/desktop-client/src/components/mobile/MobileAmountInput.jsx rename to packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx index 0bc83c5bff954acad614893a640e8d3310833076..6aa309dd32052d686d1b3abf2dc36afd09ae3d85 100644 --- a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx @@ -6,11 +6,11 @@ import { getNumberFormat, } from 'loot-core/src/shared/util'; -import { useMergedRefs } from '../../hooks/useMergedRefs'; -import { theme } from '../../style'; -import { Button } from '../common/Button'; -import { Text } from '../common/Text'; -import { View } from '../common/View'; +import { useMergedRefs } from '../../../hooks/useMergedRefs'; +import { theme } from '../../../style'; +import { Button } from '../../common/Button'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; const AmountInput = memo(function AmountInput({ focused, @@ -24,25 +24,19 @@ const AmountInput = memo(function AmountInput({ const inputRef = useRef(); const mergedInputRef = useMergedRefs(props.inputRef, inputRef); - const getInitialValue = () => Math.abs(props.value); + const initialValue = Math.abs(props.value); useEffect(() => { if (focused) { - focus(); - } - }, []); - - useEffect(() => { - if (focused) { - focus(); + inputRef.current?.focus(); } }, [focused]); useEffect(() => { setEditing(false); setText(''); - setValue(getInitialValue()); - }, [props.value]); + setValue(initialValue); + }, [initialValue]); const parseText = () => { return toRelaxedNumber(text.replace(/[,.]/, getNumberFormat().separator)); @@ -53,12 +47,6 @@ const AmountInput = memo(function AmountInput({ setEditing(true); } }; - - const focus = () => { - inputRef.current?.focus(); - setValue(getInitialValue()); - }; - const applyText = () => { const parsed = parseText(); const newValue = editing ? parsed : value; @@ -147,7 +135,7 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({ } else if (value > 0 || (zeroSign !== '-' && value === 0)) { setIsNegative(false); } - }, []); + }, [sign, value, zeroSign]); const toggleIsNegative = () => { setIsNegative(!isNegative); diff --git a/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9c2e0e27484ccd12211f9974df388e148818a1b2 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx @@ -0,0 +1,53 @@ +import React, { useEffect, useRef } from 'react'; + +import { useListBox } from '@react-aria/listbox'; +import { useListState } from '@react-stately/list'; + +import { ListBoxSection } from './ListBoxSection'; + +export function ListBox(props) { + const state = useListState(props); + const listBoxRef = useRef(); + const { listBoxProps, labelProps } = useListBox(props, state, listBoxRef); + const { loadMore } = props; + + useEffect(() => { + function loadMoreTransactions() { + if ( + Math.abs( + listBoxRef.current.scrollHeight - + listBoxRef.current.clientHeight - + listBoxRef.current.scrollTop, + ) < listBoxRef.current.clientHeight // load more when we're one screen height from the end + ) { + loadMore?.(); + } + } + const currentListBoxRef = listBoxRef.current; + currentListBoxRef?.addEventListener('scroll', loadMoreTransactions); + + return () => { + currentListBoxRef?.removeEventListener('scroll', loadMoreTransactions); + }; + }, [loadMore, state.collection]); + + return ( + <> + <div {...labelProps}>{props.label}</div> + <ul + {...listBoxProps} + ref={listBoxRef} + style={{ + padding: 0, + listStyle: 'none', + margin: 0, + width: '100%', + }} + > + {[...state.collection].map(item => ( + <ListBoxSection key={item.key} section={item} state={state} /> + ))} + </ul> + </> + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/ListBoxSection.jsx b/packages/desktop-client/src/components/mobile/transactions/ListBoxSection.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b12dc3e416114805c28e2b81f0b0e00d78d3cb69 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/ListBoxSection.jsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { useListBoxSection } from '@react-aria/listbox'; +import { css } from 'glamor'; + +import { styles, theme } from '../../../style'; + +import { Option } from './Option'; + +const zIndices = { SECTION_HEADING: 10 }; + +export function ListBoxSection({ section, state }) { + const { itemProps, headingProps, groupProps } = useListBoxSection({ + heading: section.rendered, + 'aria-label': section['aria-label'], + }); + + // The heading is rendered inside an <li> element, which contains + // a <ul> with the child items. + return ( + <li {...itemProps} style={{ width: '100%' }}> + {section.rendered && ( + <div + {...headingProps} + className={`${css(styles.smallText, { + backgroundColor: theme.pageBackground, + borderBottom: `1px solid ${theme.tableBorder}`, + borderTop: `1px solid ${theme.tableBorder}`, + color: theme.tableHeaderText, + display: 'flex', + justifyContent: 'center', + paddingBottom: 4, + paddingTop: 4, + position: 'sticky', + top: '0', + width: '100%', + zIndex: zIndices.SECTION_HEADING, + })}`} + > + {section.rendered} + </div> + )} + <ul + {...groupProps} + style={{ + padding: 0, + listStyle: 'none', + }} + > + {[...section.childNodes].map((node, index, nodes) => ( + <Option + key={node.key} + item={node} + state={state} + isLast={index === nodes.length - 1} + /> + ))} + </ul> + </li> + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/Option.jsx b/packages/desktop-client/src/components/mobile/transactions/Option.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8fc46836ee0e0be46e155675a44a3e75bb6dd415 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/Option.jsx @@ -0,0 +1,33 @@ +import React, { useRef } from 'react'; + +import { useFocusRing } from '@react-aria/focus'; +import { useOption } from '@react-aria/listbox'; +import { mergeProps } from '@react-aria/utils'; + +import { theme } from '../../../style'; + +export function Option({ isLast, item, state }) { + // Get props for the option element + const ref = useRef(); + const { optionProps, isSelected } = useOption({ key: item.key }, state, ref); + + // Determine whether we should show a keyboard + const { isFocusVisible, focusProps } = useFocusRing(); + + return ( + <li + {...mergeProps(optionProps, focusProps)} + ref={ref} + style={{ + background: isSelected + ? theme.tableRowBackgroundHighlight + : theme.tableBackground, + color: isSelected ? theme.mobileModalText : null, + outline: isFocusVisible ? '2px solid orange' : 'none', + ...(!isLast && { borderBottom: `1px solid ${theme.tableBorder}` }), + }} + > + {item.rendered} + </li> + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d6d2ceced5d9936ae861c9eb80fa0f278109b39a --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx @@ -0,0 +1,209 @@ +import React, { memo, useMemo } from 'react'; + +import { getScheduledAmount } from 'loot-core/src/shared/schedules'; +import { isPreviewId } from 'loot-core/src/shared/transactions'; +import { integerToCurrency, groupById } from 'loot-core/src/shared/util'; + +import { + SvgArrowsSynchronize, + SvgCheckCircle1, + SvgLockClosed, +} from '../../../icons/v2'; +import { styles, theme } from '../../../style'; +import { Button } from '../../common/Button'; +import { Text } from '../../common/Text'; +import { TextOneLine } from '../../common/TextOneLine'; +import { View } from '../../common/View'; + +import { lookupName, getDescriptionPretty, Status } from './TransactionEdit'; + +const ROW_HEIGHT = 50; + +const ListItem = ({ children, style, ...props }) => { + return ( + <View + style={{ + height: ROW_HEIGHT, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 10, + paddingRight: 10, + ...style, + }} + {...props} + > + {children} + </View> + ); +}; + +ListItem.displayName = 'ListItem'; + +export const Transaction = memo(function Transaction({ + transaction, + account, + accounts, + categories, + payees, + added, + onSelect, + style, +}) { + const accountsById = useMemo(() => groupById(accounts), [accounts]); + const payeesById = useMemo(() => groupById(payees), [payees]); + + const { + id, + payee: payeeId, + amount: originalAmount, + category: categoryId, + cleared, + is_parent: isParent, + notes, + schedule, + } = transaction; + + let amount = originalAmount; + if (isPreviewId(id)) { + amount = getScheduledAmount(amount); + } + + const categoryName = lookupName(categories, categoryId); + + const payee = payeesById && payeeId && payeesById[payeeId]; + const transferAcct = + payee && payee.transfer_acct && accountsById[payee.transfer_acct]; + + const prettyDescription = getDescriptionPretty( + transaction, + payee, + transferAcct, + ); + const specialCategory = account?.offbudget + ? 'Off Budget' + : transferAcct + ? 'Transfer' + : isParent + ? 'Split' + : null; + + const prettyCategory = specialCategory || categoryName; + + const isPreview = isPreviewId(id); + const isReconciled = transaction.reconciled; + const textStyle = isPreview && { + fontStyle: 'italic', + color: theme.pageTextLight, + }; + + return ( + <Button + onClick={() => onSelect(transaction)} + style={{ + backgroundColor: theme.tableBackground, + border: 'none', + width: '100%', + }} + > + <ListItem + style={{ + flex: 1, + height: 60, + padding: '5px 10px', // remove padding when Button is back + ...(isPreview && { + backgroundColor: theme.tableRowHeaderBackground, + }), + ...style, + }} + > + <View style={{ flex: 1 }}> + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + {schedule && ( + <SvgArrowsSynchronize + style={{ + width: 12, + height: 12, + marginRight: 5, + color: textStyle.color || theme.menuItemText, + }} + /> + )} + <TextOneLine + style={{ + ...styles.text, + ...textStyle, + fontSize: 14, + fontWeight: added ? '600' : '400', + ...(prettyDescription === '' && { + color: theme.tableTextLight, + fontStyle: 'italic', + }), + }} + > + {prettyDescription || 'Empty'} + </TextOneLine> + </View> + {isPreview ? ( + <Status status={notes} /> + ) : ( + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + marginTop: 3, + }} + > + {isReconciled ? ( + <SvgLockClosed + style={{ + width: 11, + height: 11, + color: theme.noticeTextLight, + marginRight: 5, + }} + /> + ) : ( + <SvgCheckCircle1 + style={{ + width: 11, + height: 11, + color: cleared + ? theme.noticeTextLight + : theme.pageTextSubdued, + marginRight: 5, + }} + /> + )} + <TextOneLine + style={{ + fontSize: 11, + marginTop: 1, + fontWeight: '400', + color: prettyCategory + ? theme.tableText + : theme.menuItemTextSelected, + fontStyle: + specialCategory || !prettyCategory ? 'italic' : undefined, + textAlign: 'left', + }} + > + {prettyCategory || 'Uncategorized'} + </TextOneLine> + </View> + )} + </View> + <Text + style={{ + ...styles.text, + ...textStyle, + marginLeft: 25, + marginRight: 5, + fontSize: 14, + }} + > + {integerToCurrency(amount)} + </Text> + </ListItem> + </Button> + ); +}); diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx similarity index 67% rename from packages/desktop-client/src/components/transactions/MobileTransaction.jsx rename to packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx index 24fc2987b82a64524deb536554ffccc9dbcc8a99..9e9be907aa153f0fa5676b65c5e1736b84707acd 100644 --- a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx @@ -6,29 +6,22 @@ import React, { memo, useMemo, } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { useFocusRing } from '@react-aria/focus'; -import { useListBox, useListBoxSection, useOption } from '@react-aria/listbox'; -import { mergeProps } from '@react-aria/utils'; -import { Item, Section } from '@react-stately/collections'; -import { useListState } from '@react-stately/list'; import { format as formatDate, parse as parseDate, parseISO, isValid as isValidDate, } from 'date-fns'; -import { css } from 'glamor'; +import { pushModal, setLastTransaction } from 'loot-core/client/actions'; import { runQuery } from 'loot-core/src/client/query-helpers'; import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { q } from 'loot-core/src/shared/query'; -import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { - isPreviewId, ungroupTransactions, updateTransaction, realizeTempTransactions, @@ -46,48 +39,35 @@ import { groupById, } from 'loot-core/src/shared/util'; -import { useAccounts } from '../../hooks/useAccounts'; -import { useActions } from '../../hooks/useActions'; -import { useCategories } from '../../hooks/useCategories'; -import { useDateFormat } from '../../hooks/useDateFormat'; -import { useNavigate } from '../../hooks/useNavigate'; -import { usePayees } from '../../hooks/usePayees'; -import { useSetThemeColor } from '../../hooks/useSetThemeColor'; +import { useAccounts } from '../../../hooks/useAccounts'; +import { useCategories } from '../../../hooks/useCategories'; +import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { usePayees } from '../../../hooks/usePayees'; +import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; import { SingleActiveEditFormProvider, useSingleActiveEditForm, -} from '../../hooks/useSingleActiveEditForm'; -import { SvgSplit } from '../../icons/v0'; -import { SvgAdd, SvgTrash } from '../../icons/v1'; -import { - SvgArrowsSynchronize, - SvgCheckCircle1, - SvgLockClosed, - SvgPencilWriteAlternate, -} from '../../icons/v2'; -import { styles, theme } from '../../style'; -import { Button } from '../common/Button'; -import { Text } from '../common/Text'; -import { TextOneLine } from '../common/TextOneLine'; -import { View } from '../common/View'; -import { FocusableAmountInput } from '../mobile/MobileAmountInput'; -import { - FieldLabel, - TapField, - InputField, - BooleanField, -} from '../mobile/MobileForms'; -import { MobileBackButton } from '../MobileBackButton'; -import { Page } from '../Page'; -import { AmountInput } from '../util/AmountInput'; - -const zIndices = { SECTION_HEADING: 10 }; +} from '../../../hooks/useSingleActiveEditForm'; +import { SvgSplit } from '../../../icons/v0'; +import { SvgAdd, SvgTrash } from '../../../icons/v1'; +import { SvgPencilWriteAlternate } from '../../../icons/v2'; +import { styles, theme } from '../../../style'; +import { Button } from '../../common/Button'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; +import { MobileBackButton } from '../../MobileBackButton'; +import { Page } from '../../Page'; +import { AmountInput } from '../../util/AmountInput'; +import { FieldLabel, TapField, InputField, BooleanField } from '../MobileForms'; + +import { FocusableAmountInput } from './FocusableAmountInput'; function getFieldName(transactionId, field) { return `${field}-${transactionId}`; } -function getDescriptionPretty(transaction, payee, transferAcct) { +export function getDescriptionPretty(transaction, payee, transferAcct) { const { amount } = transaction; if (transferAcct) { @@ -144,14 +124,14 @@ function deserializeTransaction(transaction, originalTransaction, dateFormat) { return { ...realTransaction, date, amount: amountToInteger(amount || 0) }; } -function lookupName(items, id) { +export function lookupName(items, id) { if (!id) { return null; } return items.find(item => item.id === id)?.name; } -function Status({ status }) { +export function Status({ status }) { let color; switch (status) { @@ -439,9 +419,18 @@ const TransactionEditInner = memo(function TransactionEditInner({ dateFormat, transactions: unserializedTransactions, navigate, - pushModal, ...props }) { + const dispatch = useDispatch(); + const transactions = useMemo( + () => + unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) || + [], + [unserializedTransactions, dateFormat], + ); + + const [transaction, ...childTransactions] = transactions; + const { editingField, onRequestActiveEdit, onClearActiveEdit } = useSingleActiveEditForm(); const [totalAmountFocused, setTotalAmountFocused] = useState(false); @@ -450,6 +439,20 @@ const TransactionEditInner = memo(function TransactionEditInner({ const payeesById = useMemo(() => groupById(payees), [payees]); const accountsById = useMemo(() => groupById(accounts), [accounts]); + const onTotalAmountEdit = () => { + onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => { + setTotalAmountFocused(true); + return () => setTotalAmountFocused(false); + }); + }; + + useEffect(() => { + if (adding) { + onTotalAmountEdit(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const getAccount = trans => { return trans?.account && accountsById?.[trans.account]; }; @@ -482,19 +485,6 @@ const TransactionEditInner = memo(function TransactionEditInner({ : lookupName(categories, trans.category); }; - const onTotalAmountEdit = () => { - onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => { - setTotalAmountFocused(true); - return () => setTotalAmountFocused(false); - }); - }; - - useEffect(() => { - if (adding) { - onTotalAmountEdit(); - } - }, []); - const onTotalAmountUpdate = value => { if (transaction.amount !== value) { onEdit(transaction, 'amount', value.toString()); @@ -530,10 +520,12 @@ const TransactionEditInner = memo(function TransactionEditInner({ // On the web only certain changes trigger a warning. // Should we bring that here as well? Or does the nature of the editing form // make this more appropriate? - pushModal('confirm-transaction-edit', { - onConfirm: onConfirmSave, - confirmReason: 'editReconciled', - }); + dispatch( + pushModal('confirm-transaction-edit', { + onConfirm: onConfirmSave, + confirmReason: 'editReconciled', + }), + ); } else { onConfirmSave(); } @@ -551,21 +543,23 @@ const TransactionEditInner = memo(function TransactionEditInner({ const onClick = (transactionId, name) => { onRequestActiveEdit?.(getFieldName(transaction.id, 'payee'), () => { - pushModal('edit-field', { - name, - onSubmit: (name, value) => { - const transaction = unserializedTransactions.find( - t => t.id === transactionId, - ); - // This is a deficiency of this API, need to fix. It - // assumes that it receives a serialized transaction, - // but we only have access to the raw transaction - onEdit(serializeTransaction(transaction, dateFormat), name, value); - }, - onClose: () => { - onClearActiveEdit(); - }, - }); + dispatch( + pushModal('edit-field', { + name, + onSubmit: (name, value) => { + const transaction = unserializedTransactions.find( + t => t.id === transactionId, + ); + // This is a deficiency of this API, need to fix. It + // assumes that it receives a serialized transaction, + // but we only have access to the raw transaction + onEdit(serializeTransaction(transaction, dateFormat), name, value); + }, + onClose: () => { + onClearActiveEdit(); + }, + }), + ); }); }; @@ -590,10 +584,12 @@ const TransactionEditInner = memo(function TransactionEditInner({ }; if (transaction.reconciled) { - pushModal('confirm-transaction-edit', { - onConfirm: onConfirmDelete, - confirmReason: 'deleteReconciled', - }); + dispatch( + pushModal('confirm-transaction-edit', { + onConfirm: onConfirmDelete, + confirmReason: 'deleteReconciled', + }), + ); } else { onConfirmDelete(); } @@ -619,15 +615,6 @@ const TransactionEditInner = memo(function TransactionEditInner({ scrollChildTransactionIntoView(id); }; - const transactions = useMemo( - () => - unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) || - [], - [unserializedTransactions, dateFormat], - ); - - const [transaction, ...childTransactions] = transactions; - useEffect(() => { const noAmountTransaction = childTransactions.find(t => t.amount === 0); if (noAmountTransaction) { @@ -937,6 +924,7 @@ function TransactionEditUnconnected(props) { const { categories, accounts, payees, lastTransaction, dateFormat } = props; const { id: accountId, transactionId } = useParams(); const navigate = useNavigate(); + const dispatch = useDispatch(); const [transactions, setTransactions] = useState([]); const [fetchedTransactions, setFetchedTransactions] = useState([]); const adding = useRef(false); @@ -960,7 +948,9 @@ function TransactionEditUnconnected(props) { .select('*') .options({ splits: 'grouped' }), ); - setFetchedTransactions(ungroupTransactions(data)); + const fetchedTransactions = ungroupTransactions(data); + setTransactions(fetchedTransactions); + setFetchedTransactions(fetchedTransactions); } if (transactionId) { fetchTransaction(); @@ -969,10 +959,6 @@ function TransactionEditUnconnected(props) { } }, [transactionId]); - useEffect(() => { - setTransactions(fetchedTransactions); - }, [fetchedTransactions]); - useEffect(() => { if (adding.current) { setTransactions( @@ -982,7 +968,7 @@ function TransactionEditUnconnected(props) { ), ); } - }, [adding.current, accountId, lastTransaction]); + }, [accountId, lastTransaction]); if ( categories.length === 0 || @@ -1049,7 +1035,7 @@ function TransactionEditUnconnected(props) { if (adding.current) { // The first one is always the "parent" and the only one we care // about - props.setLastTransaction(newTransactions[0]); + dispatch(setLastTransaction(newTransactions[0])); } }; @@ -1095,7 +1081,6 @@ function TransactionEditUnconnected(props) { categories={categories} accounts={accounts} payees={payees} - pushModal={props.pushModal} navigate={navigate} dateFormat={dateFormat} onEdit={onEdit} @@ -1114,13 +1099,11 @@ export const TransactionEdit = props => { const lastTransaction = useSelector(state => state.queries.lastTransaction); const accounts = useAccounts(); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; - const actions = useActions(); return ( <SingleActiveEditFormProvider formName="mobile-transaction"> <TransactionEditUnconnected {...props} - {...actions} categories={categories} payees={payees} lastTransaction={lastTransaction} @@ -1130,425 +1113,3 @@ export const TransactionEdit = props => { </SingleActiveEditFormProvider> ); }; - -const Transaction = memo(function Transaction({ - transaction, - account, - accounts, - categories, - payees, - added, - onSelect, - style, -}) { - const accountsById = useMemo(() => groupById(accounts), [accounts]); - const payeesById = useMemo(() => groupById(payees), [payees]); - - const { - id, - payee: payeeId, - amount: originalAmount, - category: categoryId, - cleared, - is_parent: isParent, - notes, - schedule, - } = transaction; - - let amount = originalAmount; - if (isPreviewId(id)) { - amount = getScheduledAmount(amount); - } - - const categoryName = lookupName(categories, categoryId); - - const payee = payeesById && payeeId && payeesById[payeeId]; - const transferAcct = - payee && payee.transfer_acct && accountsById[payee.transfer_acct]; - - const prettyDescription = getDescriptionPretty( - transaction, - payee, - transferAcct, - ); - const specialCategory = account?.offbudget - ? 'Off Budget' - : transferAcct - ? 'Transfer' - : isParent - ? 'Split' - : null; - - const prettyCategory = specialCategory || categoryName; - - const isPreview = isPreviewId(id); - const isReconciled = transaction.reconciled; - const textStyle = isPreview && { - fontStyle: 'italic', - color: theme.pageTextLight, - }; - - return ( - <Button - onClick={() => onSelect(transaction)} - style={{ - backgroundColor: theme.tableBackground, - border: 'none', - width: '100%', - }} - > - <ListItem - style={{ - flex: 1, - height: 60, - padding: '5px 10px', // remove padding when Button is back - ...(isPreview && { - backgroundColor: theme.tableRowHeaderBackground, - }), - ...style, - }} - > - <View style={{ flex: 1 }}> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - {schedule && ( - <SvgArrowsSynchronize - style={{ - width: 12, - height: 12, - marginRight: 5, - color: textStyle.color || theme.menuItemText, - }} - /> - )} - <TextOneLine - style={{ - ...styles.text, - ...textStyle, - fontSize: 14, - fontWeight: added ? '600' : '400', - ...(prettyDescription === '' && { - color: theme.tableTextLight, - fontStyle: 'italic', - }), - }} - > - {prettyDescription || 'Empty'} - </TextOneLine> - </View> - {isPreview ? ( - <Status status={notes} /> - ) : ( - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - marginTop: 3, - }} - > - {isReconciled ? ( - <SvgLockClosed - style={{ - width: 11, - height: 11, - color: theme.noticeTextLight, - marginRight: 5, - }} - /> - ) : ( - <SvgCheckCircle1 - style={{ - width: 11, - height: 11, - color: cleared - ? theme.noticeTextLight - : theme.pageTextSubdued, - marginRight: 5, - }} - /> - )} - <TextOneLine - style={{ - fontSize: 11, - marginTop: 1, - fontWeight: '400', - color: prettyCategory - ? theme.tableText - : theme.menuItemTextSelected, - fontStyle: - specialCategory || !prettyCategory ? 'italic' : undefined, - textAlign: 'left', - }} - > - {prettyCategory || 'Uncategorized'} - </TextOneLine> - </View> - )} - </View> - <Text - style={{ - ...styles.text, - ...textStyle, - marginLeft: 25, - marginRight: 5, - fontSize: 14, - }} - > - {integerToCurrency(amount)} - </Text> - </ListItem> - </Button> - ); -}); - -export function TransactionList({ - account, - accounts, - categories, - payees, - transactions, - isNew, - onSelect, - scrollProps = {}, - onLoadMore, -}) { - const sections = useMemo(() => { - // Group by date. We can assume transactions is ordered - const sections = []; - transactions.forEach(transaction => { - if ( - sections.length === 0 || - transaction.date !== sections[sections.length - 1].date - ) { - // Mark the last transaction in the section so it can render - // with a different border - const lastSection = sections[sections.length - 1]; - if (lastSection && lastSection.data.length > 0) { - const lastData = lastSection.data; - lastData[lastData.length - 1].isLast = true; - } - - sections.push({ - id: `${isPreviewId(transaction.id) ? 'preview/' : ''}${ - transaction.date - }`, - date: transaction.date, - data: [], - }); - } - - if (!transaction.is_child) { - sections[sections.length - 1].data.push(transaction); - } - }); - return sections; - }, [transactions]); - - return ( - <> - {scrollProps.ListHeaderComponent} - <ListBox - {...scrollProps} - aria-label="transaction list" - label="" - loadMore={onLoadMore} - selectionMode="none" - > - {sections.length === 0 ? ( - <Section> - <Item textValue="No transactions"> - <div - style={{ - display: 'flex', - justifyContent: 'center', - width: '100%', - backgroundColor: theme.mobilePageBackground, - }} - > - <Text style={{ fontSize: 15 }}>No transactions</Text> - </div> - </Item> - </Section> - ) : null} - {sections.map(section => { - return ( - <Section - title={ - <span>{monthUtils.format(section.date, 'MMMM dd, yyyy')}</span> - } - key={section.id} - > - {section.data.map((transaction, index, transactions) => { - return ( - <Item - key={transaction.id} - style={{ - fontSize: - index === transactions.length - 1 ? 98 : 'inherit', - }} - textValue={transaction.id} - > - <Transaction - transaction={transaction} - account={account} - categories={categories} - accounts={accounts} - payees={payees} - added={isNew(transaction.id)} - onSelect={onSelect} // onSelect(transaction)} - /> - </Item> - ); - })} - </Section> - ); - })} - </ListBox> - </> - ); -} - -function ListBox(props) { - const state = useListState(props); - const listBoxRef = useRef(); - const { listBoxProps, labelProps } = useListBox(props, state, listBoxRef); - - useEffect(() => { - function loadMoreTransactions() { - if ( - Math.abs( - listBoxRef.current.scrollHeight - - listBoxRef.current.clientHeight - - listBoxRef.current.scrollTop, - ) < listBoxRef.current.clientHeight // load more when we're one screen height from the end - ) { - props.loadMore(); - } - } - - listBoxRef.current.addEventListener('scroll', loadMoreTransactions); - - return () => { - listBoxRef.current?.removeEventListener('scroll', loadMoreTransactions); - }; - }, [state.collection]); - - return ( - <> - <div {...labelProps}>{props.label}</div> - <ul - {...listBoxProps} - ref={listBoxRef} - style={{ - padding: 0, - listStyle: 'none', - margin: 0, - width: '100%', - }} - > - {[...state.collection].map(item => ( - <ListBoxSection key={item.key} section={item} state={state} /> - ))} - </ul> - </> - ); -} - -function ListBoxSection({ section, state }) { - const { itemProps, headingProps, groupProps } = useListBoxSection({ - heading: section.rendered, - 'aria-label': section['aria-label'], - }); - - // The heading is rendered inside an <li> element, which contains - // a <ul> with the child items. - return ( - <li {...itemProps} style={{ width: '100%' }}> - {section.rendered && ( - <div - {...headingProps} - className={`${css(styles.smallText, { - backgroundColor: theme.pageBackground, - borderBottom: `1px solid ${theme.tableBorder}`, - borderTop: `1px solid ${theme.tableBorder}`, - color: theme.tableHeaderText, - display: 'flex', - justifyContent: 'center', - paddingBottom: 4, - paddingTop: 4, - position: 'sticky', - top: '0', - width: '100%', - zIndex: zIndices.SECTION_HEADING, - })}`} - > - {section.rendered} - </div> - )} - <ul - {...groupProps} - style={{ - padding: 0, - listStyle: 'none', - }} - > - {[...section.childNodes].map((node, index, nodes) => ( - <Option - key={node.key} - item={node} - state={state} - isLast={index === nodes.length - 1} - /> - ))} - </ul> - </li> - ); -} - -function Option({ isLast, item, state }) { - // Get props for the option element - const ref = useRef(); - const { optionProps, isSelected } = useOption({ key: item.key }, state, ref); - - // Determine whether we should show a keyboard - const { isFocusVisible, focusProps } = useFocusRing(); - - return ( - <li - {...mergeProps(optionProps, focusProps)} - ref={ref} - style={{ - background: isSelected - ? theme.tableRowBackgroundHighlight - : theme.tableBackground, - color: isSelected ? theme.mobileModalText : null, - outline: isFocusVisible ? '2px solid orange' : 'none', - ...(!isLast && { borderBottom: `1px solid ${theme.tableBorder}` }), - }} - > - {item.rendered} - </li> - ); -} - -const ROW_HEIGHT = 50; - -const ListItem = forwardRef(({ children, style, ...props }, ref) => { - return ( - <View - style={{ - height: ROW_HEIGHT, - flexDirection: 'row', - alignItems: 'center', - paddingLeft: 10, - paddingRight: 10, - ...style, - }} - ref={ref} - {...props} - > - {children} - </View> - ); -}); - -ListItem.displayName = 'ListItem'; diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e76fe396d028bc56b9c605a27685ba3d1c5d8a06 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx @@ -0,0 +1,117 @@ +import React, { useMemo } from 'react'; + +import { Item, Section } from '@react-stately/collections'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { isPreviewId } from 'loot-core/src/shared/transactions'; + +import { theme } from '../../../style'; +import { Text } from '../../common/Text'; + +import { ListBox } from './ListBox'; +import { Transaction } from './Transaction'; + +export function TransactionList({ + account, + accounts, + categories, + payees, + transactions, + isNew, + onSelect, + scrollProps = {}, + onLoadMore, +}) { + const sections = useMemo(() => { + // Group by date. We can assume transactions is ordered + const sections = []; + transactions.forEach(transaction => { + if ( + sections.length === 0 || + transaction.date !== sections[sections.length - 1].date + ) { + // Mark the last transaction in the section so it can render + // with a different border + const lastSection = sections[sections.length - 1]; + if (lastSection && lastSection.data.length > 0) { + const lastData = lastSection.data; + lastData[lastData.length - 1].isLast = true; + } + + sections.push({ + id: `${isPreviewId(transaction.id) ? 'preview/' : ''}${transaction.date}`, + date: transaction.date, + data: [], + }); + } + + if (!transaction.is_child) { + sections[sections.length - 1].data.push(transaction); + } + }); + return sections; + }, [transactions]); + + return ( + <> + {scrollProps.ListHeaderComponent} + <ListBox + {...scrollProps} + aria-label="transaction list" + label="" + loadMore={onLoadMore} + selectionMode="none" + > + {sections.length === 0 ? ( + <Section> + <Item textValue="No transactions"> + <div + style={{ + display: 'flex', + justifyContent: 'center', + width: '100%', + backgroundColor: theme.mobilePageBackground, + }} + > + <Text style={{ fontSize: 15 }}>No transactions</Text> + </div> + </Item> + </Section> + ) : null} + {sections.map(section => { + return ( + <Section + title={ + <span>{monthUtils.format(section.date, 'MMMM dd, yyyy')}</span> + } + key={section.id} + > + {section.data.map((transaction, index, transactions) => { + return ( + <Item + key={transaction.id} + style={{ + fontSize: + index === transactions.length - 1 ? 98 : 'inherit', + }} + textValue={transaction.id} + > + <Transaction + transaction={transaction} + account={account} + categories={categories} + accounts={accounts} + payees={payees} + added={isNew(transaction.id)} + onSelect={onSelect} // onSelect(transaction)} + /> + </Item> + ); + })} + </Section> + ); + })} + </ListBox> + </> + ); +} diff --git a/packages/desktop-client/src/components/responsive/narrow.ts b/packages/desktop-client/src/components/responsive/narrow.ts index 900ed9b9449c3d88b26ba3da3219a7892e4ebd48..d1d2b5cb42732cff15f9d1c94b967ffc2b0067ac 100644 --- a/packages/desktop-client/src/components/responsive/narrow.ts +++ b/packages/desktop-client/src/components/responsive/narrow.ts @@ -1,4 +1,4 @@ -export { Budget } from '../budget/MobileBudget'; +export { Budget } from '../mobile/budget'; -export { Accounts } from '../accounts/MobileAccounts'; -export { Account } from '../accounts/MobileAccount'; +export { Accounts } from '../mobile/accounts/Accounts'; +export { Account } from '../mobile/accounts/Account'; diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx index 350470327556c9311b6a509a1604600770b29cf3..dad536a1d2975c9d9b8fddf266249157062d9e2e 100644 --- a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx +++ b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx @@ -1,13 +1,12 @@ import React, { useMemo } from 'react'; +import { isPreviewId } from 'loot-core/shared/transactions'; import { validForTransfer } from 'loot-core/src/client/transfer'; import { useSelectedItems } from '../../hooks/useSelected'; import { Menu } from '../common/Menu'; import { SelectedItemsButton } from '../table'; -import { isPreviewId } from './TransactionsTable'; - export function SelectedTransactionsButton({ getTransaction, onShow, diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index c5e853fa5cd17e507e5855d9842c5b9c61abe6a6..2b98a047ea1e9c31b818c9983f63e6714c3eadcc 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -1,5 +1,4 @@ import React, { - createContext, createElement, createRef, forwardRef, @@ -10,10 +9,7 @@ import React, { useCallback, useLayoutEffect, useEffect, - useContext, - useReducer, } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; import { format as formatDate, @@ -37,6 +33,8 @@ import { addSplitTransaction, groupTransaction, ungroupTransactions, + isTemporaryId, + isPreviewId, } from 'loot-core/src/shared/transactions'; import { integerToCurrency, @@ -47,6 +45,7 @@ import { import { useMergedRefs } from '../../hooks/useMergedRefs'; import { usePrevious } from '../../hooks/usePrevious'; import { useSelectedDispatch, useSelectedItems } from '../../hooks/useSelected'; +import { useSplitsExpanded } from '../../hooks/useSplitsExpanded'; import { SvgLeftArrow2, SvgRightArrow2 } from '../../icons/v0'; import { SvgArrowDown, SvgArrowUp, SvgCheveronDown } from '../../icons/v1'; import { @@ -149,104 +148,6 @@ function isLastChild(transactions, index) { ); } -const SplitsExpandedContext = createContext(null); - -export function useSplitsExpanded() { - const data = useContext(SplitsExpandedContext); - - return useMemo( - () => ({ - ...data, - expanded: id => - data.state.mode === 'collapse' - ? !data.state.ids.has(id) - : data.state.ids.has(id), - }), - [data], - ); -} - -export function SplitsExpandedProvider({ children, initialMode = 'expand' }) { - const cachedState = useSelector(state => state.app.lastSplitState); - const reduxDispatch = useDispatch(); - - const [state, dispatch] = useReducer( - (state, action) => { - switch (action.type) { - case 'toggle-split': { - const ids = new Set([...state.ids]); - const { id } = action; - if (ids.has(id)) { - ids.delete(id); - } else { - ids.add(id); - } - return { ...state, ids }; - } - case 'open-split': { - const ids = new Set([...state.ids]); - const { id } = action; - if (state.mode === 'collapse') { - ids.delete(id); - } else { - ids.add(id); - } - return { ...state, ids }; - } - case 'set-mode': { - return { - ...state, - mode: action.mode, - ids: new Set(), - transitionId: null, - }; - } - case 'switch-mode': - if (state.transitionId != null) { - // You can only transition once at a time - return state; - } - - return { - ...state, - mode: state.mode === 'expand' ? 'collapse' : 'expand', - transitionId: action.id, - ids: new Set(), - }; - case 'finish-switch-mode': - return { ...state, transitionId: null }; - default: - throw new Error('Unknown action type: ' + action.type); - } - }, - cachedState.current || { ids: new Set(), mode: initialMode }, - ); - - useEffect(() => { - if (state.transitionId != null) { - // This timeout allows animations to finish - setTimeout(() => { - dispatch({ type: 'finish-switch-mode' }); - }, 250); - } - }, [state.transitionId]); - - useEffect(() => { - // In a finished state, cache the state - if (state.transitionId == null) { - reduxDispatch({ type: 'SET_LAST_SPLIT_STATE', splitState: state }); - } - }, [state]); - - const value = useMemo(() => ({ state, dispatch }), [state, dispatch]); - - return ( - <SplitsExpandedContext.Provider value={value}> - {children} - </SplitsExpandedContext.Provider> - ); -} - function selectAscDesc(field, ascDesc, clicked, defaultAscDesc = 'asc') { return field === clicked ? ascDesc === 'asc' @@ -1458,14 +1359,6 @@ function makeTemporaryTransactions( ]; } -function isTemporaryId(id) { - return id.indexOf('temp') !== -1; -} - -export function isPreviewId(id) { - return id.indexOf('preview/') !== -1; -} - function NewTransaction({ transactions, accounts, diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx index 2d1e51c2e9cdd36f86c2e3826dca10c5e33055ef..3b03b5b4fefd1c4ba0c66e78b1fac61b38829a53 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx @@ -21,9 +21,10 @@ import { import { integerToCurrency } from 'loot-core/src/shared/util'; import { SelectedProviderWithItems } from '../../hooks/useSelected'; +import { SplitsExpandedProvider } from '../../hooks/useSplitsExpanded'; import { ResponsiveProvider } from '../../ResponsiveProvider'; -import { SplitsExpandedProvider, TransactionTable } from './TransactionsTable'; +import { TransactionTable } from './TransactionsTable'; vi.mock('loot-core/src/platform/client/fetch'); vi.mock('../../hooks/useFeatureFlag', () => vi.fn().mockReturnValue(false)); diff --git a/packages/desktop-client/src/hooks/useSplitsExpanded.jsx b/packages/desktop-client/src/hooks/useSplitsExpanded.jsx new file mode 100644 index 0000000000000000000000000000000000000000..15e1a3cdebbbb6bf46169db34950f49ae387c036 --- /dev/null +++ b/packages/desktop-client/src/hooks/useSplitsExpanded.jsx @@ -0,0 +1,106 @@ +import React, { + createContext, + useMemo, + useEffect, + useContext, + useReducer, +} from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +const SplitsExpandedContext = createContext(null); + +export function useSplitsExpanded() { + const data = useContext(SplitsExpandedContext); + + return useMemo( + () => ({ + ...data, + expanded: id => + data.state.mode === 'collapse' + ? !data.state.ids.has(id) + : data.state.ids.has(id), + }), + [data], + ); +} + +export function SplitsExpandedProvider({ children, initialMode = 'expand' }) { + const cachedState = useSelector(state => state.app.lastSplitState); + const reduxDispatch = useDispatch(); + + const [state, dispatch] = useReducer( + (state, action) => { + switch (action.type) { + case 'toggle-split': { + const ids = new Set([...state.ids]); + const { id } = action; + if (ids.has(id)) { + ids.delete(id); + } else { + ids.add(id); + } + return { ...state, ids }; + } + case 'open-split': { + const ids = new Set([...state.ids]); + const { id } = action; + if (state.mode === 'collapse') { + ids.delete(id); + } else { + ids.add(id); + } + return { ...state, ids }; + } + case 'set-mode': { + return { + ...state, + mode: action.mode, + ids: new Set(), + transitionId: null, + }; + } + case 'switch-mode': + if (state.transitionId != null) { + // You can only transition once at a time + return state; + } + + return { + ...state, + mode: state.mode === 'expand' ? 'collapse' : 'expand', + transitionId: action.id, + ids: new Set(), + }; + case 'finish-switch-mode': + return { ...state, transitionId: null }; + default: + throw new Error('Unknown action type: ' + action.type); + } + }, + cachedState.current || { ids: new Set(), mode: initialMode }, + ); + + useEffect(() => { + if (state.transitionId != null) { + // This timeout allows animations to finish + setTimeout(() => { + dispatch({ type: 'finish-switch-mode' }); + }, 250); + } + }, [state.transitionId]); + + useEffect(() => { + // In a finished state, cache the state + if (state.transitionId == null) { + reduxDispatch({ type: 'SET_LAST_SPLIT_STATE', splitState: state }); + } + }, [reduxDispatch, state]); + + const value = useMemo(() => ({ state, dispatch }), [state, dispatch]); + + return ( + <SplitsExpandedContext.Provider value={value}> + {children} + </SplitsExpandedContext.Provider> + ); +} 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 72cd5dbc54b407d3099fb351de719e3b3d1043ac..c4d54d17d93a389b4f0e2055cd3c3d95c76f6181 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -116,15 +116,14 @@ type FinanceModals = { 'schedule-posts-offline-notification': null; 'switch-budget-type': { onSwitch: () => void }; 'category-menu': { - category: CategoryEntity; + categoryId: string; onSave: (category: CategoryEntity) => void; onEditNotes: (id: string) => void; - onSaveNotes: (id: string, notes: string) => void; onDelete: (categoryId: string) => void; onClose?: () => void; }; 'category-group-menu': { - group: CategoryGroupEntity; + groupId: string; onSave: (group: CategoryGroupEntity) => void; onAddCategory: (groupId: string, isIncome: boolean) => void; onEditNotes: (id: string) => void; diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts index 6142b1cc09a34a871895ccd82d7f04476aaf21a8..a1a87df9fb1978e85520b347e30336d422fd0063 100644 --- a/packages/loot-core/src/shared/transactions.ts +++ b/packages/loot-core/src/shared/transactions.ts @@ -12,6 +12,10 @@ interface TransactionEntityWithError extends TransactionEntity { _deleted?: boolean; } +export function isTemporaryId(id: string) { + return id.indexOf('temp') !== -1; +} + export function isPreviewId(id: string) { return id.indexOf('preview/') !== -1; } diff --git a/upcoming-release-notes/2425.md b/upcoming-release-notes/2425.md new file mode 100644 index 0000000000000000000000000000000000000000..62598bd67f0dd0f2a5893bd9a6ee40d58e0ceed4 --- /dev/null +++ b/upcoming-release-notes/2425.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Reorganize mobile components. diff --git a/yarn.lock b/yarn.lock index a6c9069144520264839b13f02f2576c2a03e651a..53cfcdcb71d60bcd9085df83606db0505a41d69b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -117,6 +117,7 @@ __metadata: sass: "npm:^1.70.0" swc-loader: "npm:^0.2.3" terser-webpack-plugin: "npm:^5.3.10" + usehooks-ts: "npm:^3.0.1" uuid: "npm:^9.0.1" vite: "npm:^5.0.12" vite-plugin-pwa: "npm:^0.19.0" @@ -17878,6 +17879,17 @@ __metadata: languageName: node linkType: hard +"usehooks-ts@npm:^3.0.1": + version: 3.0.1 + resolution: "usehooks-ts@npm:3.0.1" + dependencies: + lodash.debounce: "npm:^4.0.8" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + checksum: ad80424c4e7613118a1333af63220efb06a63368d1cd0b9baa96bf40615e656ab93429a32821061e86127f8de77058423accc959d7c06aa6e15823f4765d9e5b + languageName: node + linkType: hard + "utf8-byte-length@npm:^1.0.1": version: 1.0.4 resolution: "utf8-byte-length@npm:1.0.4"