diff --git a/packages/desktop-client/package.tgz b/packages/desktop-client/package.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3efd4222666456b4cfd5ca87e6b977b578176aca Binary files /dev/null and b/packages/desktop-client/package.tgz differ diff --git a/packages/desktop-client/src/components/common/Link.tsx b/packages/desktop-client/src/components/common/Link.tsx index ebe1f355210b4658f25079c0791503cff68a4219..fe92ea84470b4df835e5794b4d39f1d5d7ffd9f5 100644 --- a/packages/desktop-client/src/components/common/Link.tsx +++ b/packages/desktop-client/src/components/common/Link.tsx @@ -95,13 +95,15 @@ const ButtonLink = ({ to, style, activeStyle, ...props }: ButtonLinkProps) => { const match = useMatch({ path }); return ( <Button - className={String( - css({ - ...style, - '&[data-pressed]': activeStyle, - ...(match ? activeStyle : {}), - }), - )} + className={() => + String( + css({ + ...style, + '&[data-pressed]': activeStyle, + ...(match ? activeStyle : {}), + }), + ) + } {...props} variant={props.buttonVariant} onPress={e => { diff --git a/packages/desktop-client/src/components/mobile/accounts/Account.jsx b/packages/desktop-client/src/components/mobile/accounts/Account.jsx deleted file mode 100644 index 244ceb99b999c1a98daba6453bec3e98aab22d59..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/components/mobile/accounts/Account.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import { useParams } from 'react-router-dom'; - -import { useAccount } from '../../../hooks/useAccount'; -import { useFailedAccounts } from '../../../hooks/useFailedAccounts'; -import { useNavigate } from '../../../hooks/useNavigate'; -import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; -import { useSyncedPref } from '../../../hooks/useSyncedPref'; -import { theme, styles } from '../../../style'; -import { Button } from '../../common/Button2'; -import { Text } from '../../common/Text'; -import { View } from '../../common/View'; - -import { AccountTransactions } from './AccountTransactions'; - -export function Account() { - const failedAccounts = useFailedAccounts(); - const syncingAccountIds = useSelector(state => state.account.accountsSyncing); - - const navigate = useNavigate(); - - const [_numberFormat] = useSyncedPref('numberFormat'); - const numberFormat = _numberFormat || 'comma-dot'; - const [hideFraction] = useSyncedPref('hideFraction'); - - const { id: accountId } = useParams(); - - useSetThemeColor(theme.mobileViewTheme); - - const account = useAccount(accountId); - - if (!account) { - return null; - } - - if ( - accountId === 'budgeted' || - accountId === 'offbudget' || - accountId === 'uncategorized' - ) { - return ( - <View style={{ flex: 1, padding: 30 }}> - <Text style={(styles.text, { textAlign: 'center' })}> - There is no Mobile View at the moment - </Text> - <Button - variant="normal" - style={{ fontSize: 15, marginLeft: 10, marginTop: 10 }} - onPress={() => navigate('/accounts')} - > - Go back to Mobile Accounts - </Button> - </View> - ); - } - - return ( - <AccountTransactions - // This key forces the whole table rerender when the number - // format changes - key={numberFormat + hideFraction} - account={account} - pending={syncingAccountIds.includes(account.id)} - failed={failedAccounts && failedAccounts.has(account.id)} - /> - ); -} diff --git a/packages/desktop-client/src/components/mobile/accounts/Account.tsx b/packages/desktop-client/src/components/mobile/accounts/Account.tsx new file mode 100644 index 0000000000000000000000000000000000000000..47c2beb9505e82c57767a7ee655748cc05a5d0a5 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/accounts/Account.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { useAccount } from '../../../hooks/useAccount'; +import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; +import { useSyncedPref } from '../../../hooks/useSyncedPref'; +import { theme } from '../../../style'; + +import { AccountTransactions } from './AccountTransactions'; + +export function Account() { + const [_numberFormat] = useSyncedPref('numberFormat'); + const numberFormat = _numberFormat || 'comma-dot'; + const [hideFraction] = useSyncedPref('hideFraction'); + + const { id: accountId } = useParams(); + + useSetThemeColor(theme.mobileViewTheme); + + const account = useAccount(accountId!); + + return ( + <AccountTransactions + // This key forces the whole table rerender when the number + // format changes + key={numberFormat + hideFraction} + account={account} + accountId={accountId} + accountName={account ? account.name : accountNameFromId(accountId)} + /> + ); +} + +function accountNameFromId(id: string | undefined) { + switch (id) { + case 'budgeted': + return 'Budgeted Accounts'; + case 'offbudget': + return 'Off Budget Accounts'; + case 'uncategorized': + return 'Uncategorized'; + default: + return 'All Accounts'; + } +} diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx similarity index 63% rename from packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx rename to packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index 594025e357716082d3ce2b2d746825b840e91bce..eaf49332fb9239cb4625e8f2510628570d35d696 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -1,11 +1,12 @@ import React, { + type CSSProperties, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useDebounceCallback } from 'usehooks-ts'; @@ -24,11 +25,17 @@ import { useDefaultSchedulesQueryTransform, } from 'loot-core/client/data-hooks/schedules'; import * as queries from 'loot-core/client/queries'; -import { pagedQuery } from 'loot-core/client/query-helpers'; +import { type PagedQuery, pagedQuery } from 'loot-core/client/query-helpers'; import { listen, send } from 'loot-core/platform/client/fetch'; +import { type Query } from 'loot-core/shared/query'; import { isPreviewId } from 'loot-core/shared/transactions'; +import { + type AccountEntity, + type TransactionEntity, +} from 'loot-core/types/models'; import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useFailedAccounts } from '../../../hooks/useFailedAccounts'; import { useNavigate } from '../../../hooks/useNavigate'; import { usePreviewTransactions } from '../../../hooks/usePreviewTransactions'; import { styles, theme } from '../../../style'; @@ -40,40 +47,67 @@ import { MobileBackButton } from '../MobileBackButton'; import { AddTransactionButton } from '../transactions/AddTransactionButton'; import { TransactionListWithBalances } from '../transactions/TransactionListWithBalances'; -export function AccountTransactions({ account, pending, failed }) { - const schedulesTransform = useDefaultSchedulesQueryTransform(account.id); +export function AccountTransactions({ + account, + accountId, + accountName, +}: { + readonly account?: AccountEntity; + readonly accountId?: string; + readonly accountName: string; +}) { + const schedulesTransform = useDefaultSchedulesQueryTransform(accountId); return ( <Page header={ <MobilePageHeader title={ - <AccountName account={account} pending={pending} failed={failed} /> + account ? ( + <AccountHeader account={account} /> + ) : ( + <NameOnlyHeader accountName={accountName} /> + ) } leftContent={<MobileBackButton />} - rightContent={<AddTransactionButton accountId={account.id} />} + rightContent={<AddTransactionButton accountId={accountId} />} /> } padding={0} > <SchedulesProvider transform={schedulesTransform}> - <TransactionListWithPreviews account={account} /> + <TransactionListWithPreviews + account={account} + accountName={accountName} + accountId={accountId} + /> </SchedulesProvider> </Page> ); } -function AccountName({ account, pending, failed }) { +function AccountHeader({ account }: { readonly account: AccountEntity }) { + const failedAccounts = useFailedAccounts(); + const syncingAccountIds = useSelector(state => state.account.accountsSyncing); + const pending = useMemo( + () => syncingAccountIds.includes(account.id), + [syncingAccountIds, account.id], + ); + const failed = useMemo( + () => failedAccounts.has(account.id), + [failedAccounts, account.id], + ); + const dispatch = useDispatch(); - const onSave = account => { + const onSave = (account: AccountEntity) => { dispatch(updateAccount(account)); }; - const onSaveNotes = async (id, notes) => { + const onSaveNotes = async (id: string, notes: string) => { await send('notes-save', { id, note: notes }); }; - const onEditNotes = id => { + const onEditNotes = (id: string) => { dispatch( pushModal('notes', { id: `account-${id}`, @@ -108,7 +142,7 @@ function AccountName({ account, pending, failed }) { flexDirection: 'row', }} > - {account.bankId && ( + {account.bank && ( <div style={{ margin: 'auto', @@ -132,7 +166,7 @@ function AccountName({ account, pending, failed }) { fontSize: 17, fontWeight: 500, ...styles.underlinedText, - ...styles.lineClamp(2), + ...(styles.lineClamp(2) as CSSProperties), }} > {`${account.closed ? 'Closed: ' : ''}${account.name}`} @@ -142,11 +176,35 @@ function AccountName({ account, pending, failed }) { ); } -function TransactionListWithPreviews({ account }) { - const [currentQuery, setCurrentQuery] = useState(); +function NameOnlyHeader({ accountName }: { readonly accountName: string }) { + return ( + <View + style={{ + flexDirection: 'row', + }} + > + <Text style={{ ...(styles.lineClamp(2) as CSSProperties) }}> + {accountName} + </Text> + </View> + ); +} + +function TransactionListWithPreviews({ + account, + accountId, + accountName, +}: { + readonly account?: AccountEntity; + readonly accountId?: string; + readonly accountName: string; +}) { + const [currentQuery, setCurrentQuery] = useState<Query>(); const [isSearching, setIsSearching] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [transactions, setTransactions] = useState([]); + const [transactions, setTransactions] = useState< + ReadonlyArray<TransactionEntity> + >([]); const prependTransactions = usePreviewTransactions(); const allTransactions = useMemo( () => @@ -158,23 +216,23 @@ function TransactionListWithPreviews({ account }) { const dispatch = useDispatch(); const navigate = useNavigate(); - const onRefresh = async () => { - await dispatch(syncAndDownload(account.id)); + const onRefresh = () => { + dispatch(syncAndDownload(accountId)); }; const makeRootQuery = useCallback( - () => queries.makeTransactionsQuery(account.id).options({ splits: 'none' }), - [account.id], + () => queries.makeTransactionsQuery(accountId).options({ splits: 'none' }), + [accountId], ); - const paged = useRef(null); + const paged = useRef<PagedQuery>(); - const updateQuery = useCallback(query => { + const updateQuery = useCallback((query: Query) => { paged.current?.unsubscribe(); setIsLoading(true); paged.current = pagedQuery( query.options({ splits: 'none' }).select('*'), - data => { + (data: ReadonlyArray<TransactionEntity>) => { setTransactions(data); setIsLoading(false); }, @@ -210,9 +268,9 @@ function TransactionListWithPreviews({ account }) { }); fetchTransactions(); - dispatch(markAccountRead(account.id)); + dispatch(markAccountRead(accountId)); return () => unlisten(); - }, [account.id, dispatch, fetchTransactions]); + }, [accountId, dispatch, fetchTransactions]); const updateSearchQuery = useDebounceCallback( useCallback( @@ -236,11 +294,11 @@ function TransactionListWithPreviews({ account }) { 150, ); - const onSearch = text => { + const onSearch = (text: string) => { updateSearchQuery(text); }; - const onOpenTransaction = transaction => { + const onOpenTransaction = (transaction: TransactionEntity) => { if (!isPreviewId(transaction.id)) { navigate(`/transactions/${transaction.id}`); } else { @@ -266,22 +324,51 @@ function TransactionListWithPreviews({ account }) { paged.current?.fetchNext(); }; - const balance = queries.accountBalance(account); - const balanceCleared = queries.accountBalanceCleared(account); - const balanceUncleared = queries.accountBalanceUncleared(account); + const balanceQueries = useMemo( + () => queriesFromAccountId(accountId, account), + [accountId, account], + ); return ( <TransactionListWithBalances isLoading={isLoading} transactions={allTransactions} - balance={balance} - balanceCleared={balanceCleared} - balanceUncleared={balanceUncleared} + balance={balanceQueries.balance} + balanceCleared={balanceQueries.cleared} + balanceUncleared={balanceQueries.uncleared} onLoadMore={onLoadMore} - searchPlaceholder={`Search ${account.name}`} + searchPlaceholder={`Search ${accountName}`} onSearch={onSearch} onOpenTransaction={onOpenTransaction} onRefresh={onRefresh} /> ); } + +function queriesFromAccountId( + id: string | undefined, + entity: AccountEntity | undefined, +) { + switch (id) { + case 'budgeted': + return { + balance: queries.budgetedAccountBalance(), + }; + case 'offbudget': + return { + balance: queries.offbudgetAccountBalance(), + }; + case 'uncategorized': + return { + balance: queries.uncategorizedBalance(), + }; + default: + return entity + ? { + balance: queries.accountBalance(entity), + cleared: queries.accountBalanceCleared(entity), + uncleared: queries.accountBalanceUncleared(entity), + } + : { balance: queries.allAccountBalance() }; + } +} diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index d9b6a2da6c9c2ceea5b9a79ec8b06e1e6959370a..abd5438d356910d9c09d68c5be51bd192b0d4663 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -7,7 +7,11 @@ import memoizeOne from 'memoize-one'; import { collapseModals, pushModal } from 'loot-core/client/actions'; import { groupById, integerToCurrency } from 'loot-core/shared/util'; -import { envelopeBudget, trackingBudget } from 'loot-core/src/client/queries'; +import { + envelopeBudget, + trackingBudget, + uncategorizedCount, +} from 'loot-core/src/client/queries'; import * as monthUtils from 'loot-core/src/shared/months'; import { useCategories } from '../../../hooks/useCategories'; @@ -33,6 +37,7 @@ import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util'; import { Button } from '../../common/Button2'; import { Card } from '../../common/Card'; import { Label } from '../../common/Label'; +import { Link } from '../../common/Link'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; import { MobilePageHeader, Page } from '../../Page'; @@ -1454,6 +1459,38 @@ function IncomeGroup({ ); } +function UncategorizedButton() { + const count = useSheetValue(uncategorizedCount()); + if (count === null || count <= 0) { + return null; + } + + return ( + <View + style={{ + padding: 5, + paddingBottom: 2, + }} + > + <Link + variant="button" + type="button" + buttonVariant="primary" + to="/accounts/uncategorized" + style={{ + border: 0, + justifyContent: 'flex-start', + padding: '1.25em', + }} + > + {count} uncategorized {count === 1 ? 'transaction' : 'transactions'} + <View style={{ flex: 1 }} /> + <SvgArrowThinRight width="15" height="15" /> + </Link> + </View> + ); +} + function BudgetGroups({ type, categoryGroups, @@ -1636,6 +1673,7 @@ export function BudgetTable({ paddingBottom: MOBILE_NAV_HEIGHT, }} > + <UncategorizedButton /> <BudgetGroups type={type} categoryGroups={categoryGroups} diff --git a/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx b/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx index edaefb73e14d104b2eaffa5211be3dce185ba7f1..7b1ca2a9498499ecdc68eaefdc485a69f8905bcf 100644 --- a/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx @@ -5,7 +5,7 @@ import { SvgAdd } from '../../../icons/v1'; import { Button } from '../../common/Button2'; type AddTransactionButtonProps = { - to: string; + to?: string; accountId?: string; categoryId?: string; }; diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx index 124ebcf0bf31989c7469036639f04f1e9f5d0abb..4a03b2c8fede379e5197b3994bdcf73226e2a6d6 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx @@ -74,7 +74,6 @@ export function TransactionListWithBalances({ return newTransactions.includes(id); }; - const unclearedAmount = useSheetValue(balanceUncleared); const selectedInst = useSelected('transactions', transactions); return ( @@ -91,73 +90,15 @@ export function TransactionListWithBalances({ justifyContent: 'space-evenly', }} > - <View - style={{ - display: !unclearedAmount ? 'none' : undefined, - flexBasis: '33%', - }} - > - <Label - title="Cleared" - style={{ textAlign: 'center', fontSize: 12 }} + {balanceCleared && balanceUncleared ? ( + <BalanceWithCleared + balance={balance} + balanceCleared={balanceCleared} + balanceUncleared={balanceUncleared} /> - <CellValue binding={balanceCleared} type="financial"> - {props => ( - <CellValueText - {...props} - style={{ - fontSize: 12, - textAlign: 'center', - fontWeight: '500', - }} - /> - )} - </CellValue> - </View> - <View style={{ flexBasis: '33%' }}> - <Label title="Balance" style={{ textAlign: 'center' }} /> - <CellValue binding={balance} type="financial"> - {props => ( - <CellValueText - {...props} - style={{ - fontSize: 18, - textAlign: 'center', - fontWeight: '500', - color: - props.value < 0 - ? theme.errorText - : theme.pillTextHighlighted, - }} - data-testid="transactions-balance" - /> - )} - </CellValue> - </View> - <View - style={{ - display: !unclearedAmount ? 'none' : undefined, - flexBasis: '33%', - }} - > - <Label - title="Uncleared" - style={{ textAlign: 'center', fontSize: 12 }} - /> - <CellValue binding={balanceUncleared} type="financial"> - {props => ( - <CellValueText - {...props} - style={{ - fontSize: 12, - textAlign: 'center', - fontWeight: '500', - }} - data-testid="transactions-balance-uncleared" - /> - )} - </CellValue> - </View> + ) : ( + <Balance balance={balance} /> + )} </View> <TransactionSearchInput placeholder={searchPlaceholder} @@ -176,3 +117,81 @@ export function TransactionListWithBalances({ </SelectedProvider> ); } + +function BalanceWithCleared({ balanceUncleared, balanceCleared, balance }) { + const unclearedAmount = useSheetValue(balanceUncleared); + + return ( + <> + <View + style={{ + display: !unclearedAmount ? 'none' : undefined, + flexBasis: '33%', + }} + > + <Label title="Cleared" style={{ textAlign: 'center', fontSize: 12 }} /> + <CellValue binding={balanceCleared} type="financial"> + {props => ( + <CellValueText + {...props} + style={{ + fontSize: 12, + textAlign: 'center', + fontWeight: '500', + }} + data-testid="transactions-balance-cleared" + /> + )} + </CellValue> + </View> + <Balance balance={balance} /> + <View + style={{ + display: !unclearedAmount ? 'none' : undefined, + flexBasis: '33%', + }} + > + <Label + title="Uncleared" + style={{ textAlign: 'center', fontSize: 12 }} + /> + <CellValue binding={balanceUncleared} type="financial"> + {props => ( + <CellValueText + {...props} + style={{ + fontSize: 12, + textAlign: 'center', + fontWeight: '500', + }} + data-testid="transactions-balance-uncleared" + /> + )} + </CellValue> + </View> + </> + ); +} + +function Balance({ balance }) { + return ( + <View style={{ flexBasis: '33%' }}> + <Label title="Balance" style={{ textAlign: 'center' }} /> + <CellValue binding={balance} type="financial"> + {props => ( + <CellValueText + {...props} + style={{ + fontSize: 18, + textAlign: 'center', + fontWeight: '500', + color: + props.value < 0 ? theme.errorText : theme.pillTextHighlighted, + }} + data-testid="transactions-balance" + /> + )} + </CellValue> + </View> + ); +} diff --git a/packages/desktop-client/src/hooks/usePreviewTransactions.ts b/packages/desktop-client/src/hooks/usePreviewTransactions.ts index 786e369c311eccc4dc19854208410a89adff3cd4..3278237e45af97cabc018b21555e9ff74ea28c34 100644 --- a/packages/desktop-client/src/hooks/usePreviewTransactions.ts +++ b/packages/desktop-client/src/hooks/usePreviewTransactions.ts @@ -11,7 +11,7 @@ import { type ScheduleEntity } from 'loot-core/types/models'; import { type TransactionEntity } from '../../../loot-core/src/types/models/transaction.d'; export function usePreviewTransactions( - collapseTransactions: (ids: string[]) => void, + collapseTransactions?: (ids: string[]) => void, ) { const scheduleData = useCachedSchedules(); const [previousScheduleData, setPreviousScheduleData] = @@ -53,7 +53,9 @@ export function usePreviewTransactions( })), })); setPreviewTransactions(ungroupTransactions(withDefaults)); - collapseTransactions(withDefaults.map(t => t.id)); + if (collapseTransactions) { + collapseTransactions(withDefaults.map(t => t.id)); + } }); } diff --git a/packages/loot-core/src/client/query-helpers.ts b/packages/loot-core/src/client/query-helpers.ts index 92f058b9f5e79956987e801e5a4787423d735af8..8a130d80e0e3e822fa8b5a5b8cbe1be8011062df 100644 --- a/packages/loot-core/src/client/query-helpers.ts +++ b/packages/loot-core/src/client/query-helpers.ts @@ -184,7 +184,7 @@ export class LiveQuery<Response = unknown> { } // Paging -class PagedQuery extends LiveQuery { +export class PagedQuery extends LiveQuery { done; onPageData; pageCount; diff --git a/upcoming-release-notes/3326.md b/upcoming-release-notes/3326.md new file mode 100644 index 0000000000000000000000000000000000000000..196c56cf2bc308681ee91f8ea6e4a7d56e2cbe23 --- /dev/null +++ b/upcoming-release-notes/3326.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [tim-smart] +--- + +Add an uncategorized transaction button to the mobile app.