From 09c44d351d222568cfc426cee44a0308883508d1 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com> Date: Mon, 12 Aug 2024 14:53:14 -0600 Subject: [PATCH] [Mobile] Long press transaction to reveal floating action bar with bulk actions (#2892) * Mobile transaction long press * Floating action bar * Styling * Add functionality * Fix typecheck error * Release notes * Undo notifications * Fix schedules and update transaction delete confirmation message * Use react-aria useLongPress * Bulk edit amount display * Themes * Do not clear on batch update * useUndo hook * Fix typecheck error * Update useUndo * Fix typecheck error * Handle batch deleted transactions * useMemo * Make onClearSelectedTransactions mandatory * Extract FloatingActionBar to a separate component * Require onAddSelectedTransaction and onClearSelectedTransactions if there are any selectedTransactions * Fix schedule link * Undo notification timeout * Use useSelected * Fix typecheck error * Category transactions batch updates * Remove undo notification title * Fix types * Fix notes undo notification * Move SelectedProvider to TransactionListWithBalances * Remove NewPayeeEntity * Disable support for amount batch edit for now * Fix lint error * Notification inset + reuse useTransactionBatchActions * Always show notification close button regardless if sticky or not * Allow clicking action bar when notifications are present * Fix typecheck error * Remove inset on addNotification calls * Use PressResponder * Fix mobile transaction border * VRT * VRT * VRT * VRT --- packages/desktop-client/package.json | 1 + .../desktop-client/src/components/Modals.tsx | 2 + .../src/components/Notifications.tsx | 57 ++- .../src/components/accounts/Account.jsx | 276 ++---------- .../src/components/accounts/Header.jsx | 6 +- .../components/mobile/FloatingActionBar.tsx | 30 ++ .../mobile/accounts/AccountTransactions.jsx | 4 +- .../mobile/budget/CategoryTransactions.jsx | 4 +- .../mobile/transactions/Transaction.jsx | 247 ++++++----- .../mobile/transactions/TransactionList.jsx | 375 +++++++++++++++- .../TransactionListWithBalances.jsx | 10 +- .../modals/ConfirmTransactionDelete.tsx | 6 +- .../src/components/payees/ManagePayees.jsx | 4 +- .../src/components/payees/PayeeTableRow.tsx | 6 +- .../src/components/rules/RuleRow.tsx | 6 +- .../src/components/rules/RulesHeader.tsx | 4 +- .../schedules/DiscoverSchedules.tsx | 18 +- .../src/components/schedules/ScheduleLink.tsx | 12 +- .../SelectedTransactionsButton.jsx | 18 +- .../transactions/SimpleTransactionsTable.jsx | 13 +- .../transactions/TransactionsTable.jsx | 10 +- .../desktop-client/src/hooks/useSelected.tsx | 30 +- .../src/hooks/useTransactionBatchActions.ts | 418 ++++++++++++++++++ packages/desktop-client/src/hooks/useUndo.ts | 67 +++ .../desktop-client/src/style/themes/dark.ts | 5 + .../src/style/themes/development.ts | 5 + .../desktop-client/src/style/themes/light.ts | 5 + .../src/style/themes/midnight.ts | 5 + .../src/client/actions/notifications.ts | 12 +- packages/loot-core/src/client/constants.ts | 1 + .../src/client/reducers/notifications.ts | 6 + .../src/client/state-types/modals.d.ts | 24 +- .../src/client/state-types/notifications.d.ts | 19 +- packages/loot-core/src/mocks/budget.ts | 54 ++- .../src/server/accounts/transactions.ts | 8 +- .../loot-core/src/shared/transactions.test.ts | 17 +- packages/loot-core/src/shared/transactions.ts | 54 ++- packages/loot-core/src/shared/util.ts | 21 +- .../loot-core/src/types/models/category.d.ts | 4 +- .../loot-core/src/types/models/payee.d.ts | 12 +- .../src/types/models/transaction.d.ts | 37 +- .../loot-core/src/types/server-handlers.d.ts | 7 +- upcoming-release-notes/2892.md | 6 + yarn.lock | 1 + 44 files changed, 1395 insertions(+), 532 deletions(-) create mode 100644 packages/desktop-client/src/components/mobile/FloatingActionBar.tsx create mode 100644 packages/desktop-client/src/hooks/useTransactionBatchActions.ts create mode 100644 packages/desktop-client/src/hooks/useUndo.ts create mode 100644 upcoming-release-notes/2892.md diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 8aa94a5bf..922c64853 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -52,6 +52,7 @@ "promise-retry": "^2.0.1", "re-resizable": "^6.9.17", "react": "18.2.0", + "react-aria": "^3.33.1", "react-aria-components": "^1.2.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index a594d445f..ea1381029 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -157,6 +157,7 @@ export function Modals() { return ( <ConfirmTransactionDelete key={name} + message={options.message} onConfirm={options.onConfirm} /> ); @@ -342,6 +343,7 @@ export function Modals() { transactionIds={options?.transactionIds} getTransaction={options?.getTransaction} accountName={options?.accountName} + onScheduleLinked={options?.onScheduleLinked} /> ); diff --git a/packages/desktop-client/src/components/Notifications.tsx b/packages/desktop-client/src/components/Notifications.tsx index 185241524..4fe464a3e 100644 --- a/packages/desktop-client/src/components/Notifications.tsx +++ b/packages/desktop-client/src/components/Notifications.tsx @@ -5,12 +5,12 @@ import React, { useMemo, type SetStateAction, } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { removeNotification } from 'loot-core/client/actions'; import { type State } from 'loot-core/src/client/state-types'; import type { NotificationWithId } from 'loot-core/src/client/state-types/notifications'; -import { useActions } from '../hooks/useActions'; import { AnimatedLoading } from '../icons/AnimatedLoading'; import { SvgDelete } from '../icons/v0'; import { useResponsive } from '../ResponsiveProvider'; @@ -120,6 +120,11 @@ function Notification({ [message, messageActions], ); + const { isNarrowWidth } = useResponsive(); + const narrowStyle: CSSProperties = isNarrowWidth + ? { minHeight: styles.mobileMinHeight } + : {}; + return ( <View style={{ @@ -133,10 +138,11 @@ function Notification({ > <Stack align="center" + justify="space-between" direction="row" style={{ padding: '14px 14px', - fontSize: 14, + ...styles.mediumText, backgroundColor: positive ? theme.noticeBackgroundLight : error @@ -156,7 +162,15 @@ function Notification({ > <Stack align="flex-start"> {title && ( - <View style={{ fontWeight: 700, marginBottom: 10 }}>{title}</View> + <View + style={{ + ...styles.mediumText, + fontWeight: 700, + marginBottom: 10, + }} + > + {title} + </View> )} <View>{processedMessage}</View> {pre @@ -196,7 +210,7 @@ function Notification({ : theme.warningBorder }`, color: 'currentColor', - fontSize: 14, + ...styles.mediumText, flexShrink: 0, ...(isHovered || isPressed ? { @@ -207,22 +221,21 @@ function Notification({ : theme.warningBackground, } : {}), + ...narrowStyle, })} > {button.title} </ButtonWithLoading> )} </Stack> - {sticky && ( - <Button - variant="bare" - aria-label="Close" - style={{ flexShrink: 0, color: 'currentColor' }} - onPress={onRemove} - > - <SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} /> - </Button> - )} + <Button + variant="bare" + aria-label="Close" + style={{ flexShrink: 0, color: 'currentColor' }} + onPress={onRemove} + > + <SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} /> + </Button> </Stack> {overlayLoading && ( <View @@ -247,18 +260,22 @@ function Notification({ } export function Notifications({ style }: { style?: CSSProperties }) { - const { removeNotification } = useActions(); + const dispatch = useDispatch(); const { isNarrowWidth } = useResponsive(); const notifications = useSelector( (state: State) => state.notifications.notifications, ); + const notificationInset = useSelector( + (state: State) => state.notifications.inset, + ); return ( <View style={{ position: 'fixed', - bottom: 20, - right: 13, - left: isNarrowWidth ? 13 : undefined, + bottom: notificationInset?.bottom || 20, + top: notificationInset?.top, + right: notificationInset?.right || 13, + left: notificationInset?.left || (isNarrowWidth ? 13 : undefined), zIndex: 10000, ...style, }} @@ -271,7 +288,7 @@ export function Notifications({ style }: { style?: CSSProperties }) { if (note.onClose) { note.onClose(); } - removeNotification(note.id); + dispatch(removeNotification(note.id)); }} /> ))} diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index dba17d9f6..06fddb35e 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -15,11 +15,9 @@ import * as queries from 'loot-core/src/client/queries'; import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import { currentDay } from 'loot-core/src/shared/months'; -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 { - deleteTransaction, updateTransaction, realizeTempTransactions, ungroupTransaction, @@ -42,6 +40,7 @@ import { useSplitsExpanded, } from '../../hooks/useSplitsExpanded'; import { useSyncedPref } from '../../hooks/useSyncedPref'; +import { useTransactionBatchActions } from '../../hooks/useTransactionBatchActions'; import { styles, theme } from '../../style'; import { Button } from '../common/Button2'; import { Text } from '../common/Text'; @@ -818,241 +817,26 @@ class AccountInternal extends PureComponent { }); }; - onBatchEdit = async (name, ids) => { - const { data } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids } }) - .select('*') - .options({ splits: 'grouped' }), - ); - const transactions = ungroupTransactions(data); - - const onChange = async (name, value, mode) => { - let transactionsToChange = transactions; - - const newValue = value === null ? '' : value; - this.setState({ workingHard: true }); - - const changes = { deleted: [], updated: [] }; - - // Cleared is a special case right now - if (name === 'cleared') { - // Clear them if any are uncleared, otherwise unclear them - value = !!transactionsToChange.find(t => !t.cleared); - } - - const idSet = new Set(ids); - - transactionsToChange.forEach(trans => { - if (name === 'cleared' && trans.reconciled) { - // Skip transactions that are reconciled. Don't want to set them as - // uncleared. - return; - } - - if (!idSet.has(trans.id)) { - // Skip transactions which aren't actually selected, since the query - // above also retrieves the siblings & parent of any selected splits. - return; - } - - if (name === 'notes') { - if (mode === 'prepend') { - value = - trans.notes === null ? newValue : newValue + ' ' + trans.notes; - } else if (mode === 'append') { - value = - trans.notes === null ? newValue : trans.notes + ' ' + newValue; - } else if (mode === 'replace') { - value = newValue; - } - } - const transaction = { - ...trans, - [name]: value, - }; + onBatchEdit = (name, ids) => { + this.props.onBatchEdit({ + name, + ids, + onSuccess: updatedIds => { + this.refetchTransactions(); - if (name === 'account' && trans.account !== value) { - transaction.reconciled = false; + if (this.table.current) { + this.table.current.edit(updatedIds[0], 'select', false); } - - const { diff } = updateTransaction(transactionsToChange, transaction); - - // TODO: We need to keep an updated list of transactions so - // the logic in `updateTransaction`, particularly about - // updating split transactions, works. This isn't ideal and we - // should figure something else out - transactionsToChange = applyChanges(diff, transactionsToChange); - - changes.deleted = changes.deleted - ? changes.deleted.concat(diff.deleted) - : diff.deleted; - changes.updated = changes.updated - ? changes.updated.concat(diff.updated) - : diff.updated; - changes.added = changes.added - ? changes.added.concat(diff.added) - : diff.added; - }); - - await send('transactions-batch-update', changes); - await this.refetchTransactions(); - - if (this.table.current) { - this.table.current.edit(transactionsToChange[0].id, 'select', false); - } - }; - - const pushPayeeAutocompleteModal = () => { - this.props.pushModal('payee-autocomplete', { - onSelect: payeeId => onChange(name, payeeId), - }); - }; - - const pushAccountAutocompleteModal = () => { - this.props.pushModal('account-autocomplete', { - onSelect: accountId => onChange(name, accountId), - }); - }; - - const pushCategoryAutocompleteModal = () => { - // Only show balances when all selected transaction are in the same month. - const transactionMonth = transactions[0]?.date - ? monthUtils.monthFromDate(transactions[0]?.date) - : null; - const transactionsHaveSameMonth = - transactionMonth && - transactions.every( - t => monthUtils.monthFromDate(t.date) === transactionMonth, - ); - this.props.pushModal('category-autocomplete', { - month: transactionsHaveSameMonth ? transactionMonth : undefined, - onSelect: categoryId => onChange(name, categoryId), - }); - }; - - if ( - name === 'amount' || - name === 'payee' || - name === 'account' || - name === 'date' - ) { - const reconciledTransactions = transactions.filter(t => t.reconciled); - if (reconciledTransactions.length > 0) { - this.props.pushModal('confirm-transaction-edit', { - onConfirm: () => { - if (name === 'payee') { - pushPayeeAutocompleteModal(); - } else if (name === 'account') { - pushAccountAutocompleteModal(); - } else { - this.props.pushModal('edit-field', { name, onSubmit: onChange }); - } - }, - confirmReason: 'batchEditWithReconciled', - }); - return; - } - } - - if (name === 'cleared') { - // Cleared just toggles it on/off and it depends on the data - // loaded. Need to clean this up in the future. - onChange('cleared', null); - } else if (name === 'category') { - pushCategoryAutocompleteModal(); - } else if (name === 'payee') { - pushPayeeAutocompleteModal(); - } else if (name === 'account') { - pushAccountAutocompleteModal(); - } else { - this.props.pushModal('edit-field', { name, onSubmit: onChange }); - } + }, + }); }; - onBatchDuplicate = async ids => { - const onConfirmDuplicate = async ids => { - this.setState({ workingHard: true }); - - const { data } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids } }) - .select('*') - .options({ splits: 'grouped' }), - ); - - const changes = { - added: data - .reduce((newTransactions, trans) => { - return newTransactions.concat( - realizeTempTransactions(ungroupTransaction(trans)), - ); - }, []) - .map(({ sort_order, ...trans }) => ({ ...trans })), - }; - - await send('transactions-batch-update', changes); - - await this.refetchTransactions(); - }; - - await this.checkForReconciledTransactions( - ids, - 'batchDuplicateWithReconciled', - onConfirmDuplicate, - ); + onBatchDuplicate = ids => { + this.props.onBatchDuplicate({ ids, onSuccess: this.refetchTransactions }); }; - onBatchDelete = async ids => { - const onConfirmDelete = async ids => { - this.setState({ workingHard: true }); - - const { data } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids } }) - .select('*') - .options({ splits: 'grouped' }), - ); - let transactions = ungroupTransactions(data); - - const idSet = new Set(ids); - const changes = { deleted: [], updated: [] }; - - transactions.forEach(trans => { - const parentId = trans.parent_id; - - // First, check if we're actually deleting this transaction by - // checking `idSet`. Then, we don't need to do anything if it's - // a child transaction and the parent is already being deleted - if (!idSet.has(trans.id) || (parentId && idSet.has(parentId))) { - return; - } - - const { diff } = deleteTransaction(transactions, trans.id); - - // TODO: We need to keep an updated list of transactions so - // the logic in `updateTransaction`, particularly about - // updating split transactions, works. This isn't ideal and we - // should figure something else out - transactions = applyChanges(diff, transactions); - - changes.deleted = diff.deleted - ? changes.deleted.concat(diff.deleted) - : diff.deleted; - changes.updated = diff.updated - ? changes.updated.concat(diff.updated) - : diff.updated; - }); - - await send('transactions-batch-update', changes); - await this.refetchTransactions(); - }; - - await this.checkForReconciledTransactions( - ids, - 'batchDeleteWithReconciled', - onConfirmDelete, - ); + onBatchDelete = ids => { + this.props.onBatchDelete({ ids, onSuccess: this.refetchTransactions }); }; onMakeAsSplitTransaction = async ids => { @@ -1183,12 +967,19 @@ class AccountInternal extends PureComponent { } }; - onBatchUnlink = async ids => { - await send('transactions-batch-update', { - updated: ids.map(id => ({ id, schedule: null })), + onBatchLinkSchedule = ids => { + this.props.onBatchLinkSchedule({ + ids, + account: this.props.accounts.find(a => a.id === this.props.accountId), + onSuccess: this.refetchTransactions, }); + }; - await this.refetchTransactions(); + onBatchUnlinkSchedule = ids => { + this.props.onBatchUnlinkSchedule({ + ids, + onSuccess: this.refetchTransactions, + }); }; onCreateRule = async ids => { @@ -1714,7 +1505,8 @@ class AccountInternal extends PureComponent { onBatchDelete={this.onBatchDelete} onBatchDuplicate={this.onBatchDuplicate} onBatchEdit={this.onBatchEdit} - onBatchUnlink={this.onBatchUnlink} + onBatchLinkSchedule={this.onBatchLinkSchedule} + onBatchUnlinkSchedule={this.onBatchUnlinkSchedule} onCreateRule={this.onCreateRule} onUpdateFilter={this.onUpdateFilter} onClearFilters={this.onClearFilters} @@ -1802,10 +1594,22 @@ class AccountInternal extends PureComponent { function AccountHack(props) { const { dispatch: splitsExpandedDispatch } = useSplitsExpanded(); + const { + onBatchEdit, + onBatchDuplicate, + onBatchLinkSchedule, + onBatchUnlinkSchedule, + onBatchDelete, + } = useTransactionBatchActions(); return ( <AccountInternal splitsExpandedDispatch={splitsExpandedDispatch} + onBatchEdit={onBatchEdit} + onBatchDuplicate={onBatchDuplicate} + onBatchLinkSchedule={onBatchLinkSchedule} + onBatchUnlinkSchedule={onBatchUnlinkSchedule} + onBatchDelete={onBatchDelete} {...props} /> ); diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx index 84f74aad9..91204e6b3 100644 --- a/packages/desktop-client/src/components/accounts/Header.jsx +++ b/packages/desktop-client/src/components/accounts/Header.jsx @@ -74,7 +74,8 @@ export function AccountHeader({ onBatchDelete, onBatchDuplicate, onBatchEdit, - onBatchUnlink, + onBatchLinkSchedule, + onBatchUnlinkSchedule, onCreateRule, onApplyFilter, onUpdateFilter, @@ -343,7 +344,8 @@ export function AccountHeader({ onDuplicate={onBatchDuplicate} onDelete={onBatchDelete} onEdit={onBatchEdit} - onUnlink={onBatchUnlink} + onLinkSchedule={onBatchLinkSchedule} + onUnlinkSchedule={onBatchUnlinkSchedule} onCreateRule={onCreateRule} onSetTransfer={onSetTransfer} onScheduleAction={onScheduleAction} diff --git a/packages/desktop-client/src/components/mobile/FloatingActionBar.tsx b/packages/desktop-client/src/components/mobile/FloatingActionBar.tsx new file mode 100644 index 000000000..0f2cf928b --- /dev/null +++ b/packages/desktop-client/src/components/mobile/FloatingActionBar.tsx @@ -0,0 +1,30 @@ +import { type PropsWithChildren } from 'react'; + +import { theme, type CSSProperties } from '../../style'; +import { View } from '../common/View'; + +type FloatingActionBarProps = PropsWithChildren & { + style: CSSProperties; +}; + +export function FloatingActionBar({ style, children }: FloatingActionBarProps) { + return ( + <View + style={{ + backgroundColor: theme.floatingActionBarBackground, + color: theme.floatingActionBarText, + position: 'fixed', + bottom: 10, + margin: '0 10px', + width: '95vw', + height: 60, + zIndex: 100, + borderRadius: 8, + border: `1px solid ${theme.floatingActionBarBorder}`, + ...style, + }} + > + {children} + </View> + ); +} diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx index b75882fae..40ccaeaab 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx @@ -235,7 +235,7 @@ function TransactionListWithPreviews({ account }) { updateSearchQuery(text); }; - const onSelectTransaction = transaction => { + const onOpenTransaction = transaction => { if (!isPreviewId(transaction.id)) { navigate(`/transactions/${transaction.id}`); } else { @@ -275,7 +275,7 @@ function TransactionListWithPreviews({ account }) { onLoadMore={onLoadMore} searchPlaceholder={`Search ${account.name}`} onSearch={onSearch} - onSelectTransaction={onSelectTransaction} + onOpenTransaction={onOpenTransaction} onRefresh={onRefresh} /> ); diff --git a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx index 1b9ddee52..7b0f6c7cf 100644 --- a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx +++ b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx @@ -111,7 +111,7 @@ export function CategoryTransactions({ category, month }) { paged.current?.fetchNext(); }; - const onSelectTransaction = transaction => { + const onOpenTranasction = transaction => { // details of how the native app used to handle preview transactions here can be found at commit 05e58279 if (!isPreviewId(transaction.id)) { navigate(`/transactions/${transaction.id}`); @@ -149,7 +149,7 @@ export function CategoryTransactions({ category, month }) { searchPlaceholder={`Search ${category.name}`} onSearch={onSearch} onLoadMore={onLoadMore} - onSelectTransaction={onSelectTransaction} + onOpenTransaction={onOpenTranasction} /> </Page> ); diff --git a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx index 2df640e50..328f5b929 100644 --- a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx @@ -1,5 +1,12 @@ import React, { memo } from 'react'; +import { + PressResponder, + usePress, + useLongPress, +} from '@react-aria/interactions'; +import { mergeProps } from '@react-aria/utils'; + import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { isPreviewId } from 'loot-core/src/shared/transactions'; import { integerToCurrency } from 'loot-core/src/shared/util'; @@ -46,8 +53,10 @@ ListItem.displayName = 'ListItem'; export const Transaction = memo(function Transaction({ transaction, - added, - onSelect, + isAdded, + isSelected, + onPress, + onLongPress, style, }) { const { list: categories } = useCategories(); @@ -70,6 +79,24 @@ export const Transaction = memo(function Transaction({ const transferAcct = useAccount(payee?.transfer_acct); const isPreview = isPreviewId(id); + + const { longPressProps } = useLongPress({ + accessibilityDescription: 'Long press to select multiple transactions', + onLongPress: () => { + if (isPreview) { + return; + } + + onLongPress(transaction); + }, + }); + + const { pressProps } = usePress({ + onPress: () => { + onPress(transaction); + }, + }); + let amount = originalAmount; if (isPreview) { amount = getScheduledAmount(amount); @@ -99,124 +126,134 @@ export const Transaction = memo(function Transaction({ }; return ( - <Button - onPress={() => { - onSelect(transaction); - }} - style={{ - backgroundColor: theme.tableBackground, - border: 'none', - width: '100%', - height: 60, - ...(isPreview && { - backgroundColor: theme.tableRowHeaderBackground, - }), - }} - > - <ListItem + <PressResponder {...mergeProps(pressProps, longPressProps)}> + <Button style={{ - flex: 1, - ...style, + backgroundColor: theme.tableBackground, + ...(isSelected + ? { + borderWidth: '0 0 0 4px', + borderColor: theme.mobileTransactionSelected, + borderStyle: 'solid', + } + : { + border: 'none', + }), + userSelect: 'none', + width: '100%', + height: 60, + ...(isPreview + ? { + backgroundColor: theme.tableRowHeaderBackground, + } + : {}), }} > - <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, - }} - /> - )} - {(isParent || isChild) && ( - <SvgSplit + <ListItem + style={{ + flex: 1, + ...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={{ - fontSize: 11, - marginTop: 1, - fontWeight: '400', - color: prettyCategory - ? theme.tableText - : theme.menuItemTextSelected, - fontStyle: - specialCategory || !prettyCategory ? 'italic' : undefined, - textAlign: 'left', + ...styles.text, + ...textStyle, + fontSize: 14, + fontWeight: isAdded ? '600' : '400', + ...(prettyDescription === '' && { + color: theme.tableTextLight, + fontStyle: 'italic', + }), }} > - {prettyCategory || 'Uncategorized'} + {prettyDescription || 'Empty'} </TextOneLine> </View> - )} - </View> - <Text - style={{ - ...styles.text, - ...textStyle, - marginLeft: 25, - marginRight: 5, - fontSize: 14, - ...makeAmountFullStyle(amount), - }} - > - {integerToCurrency(amount)} - </Text> - </ListItem> - </Button> + {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, + }} + /> + )} + {(isParent || isChild) && ( + <SvgSplit + style={{ + width: 12, + height: 12, + 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, + ...makeAmountFullStyle(amount), + }} + > + {integerToCurrency(amount)} + </Text> + </ListItem> + </Button> + </PressResponder> ); }); diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx index 6a1d4ba98..34a310e53 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx @@ -1,23 +1,50 @@ -import React, { useMemo } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch } from 'react-redux'; import { Item, Section } from '@react-stately/collections'; +import { setNotificationInset } from 'loot-core/client/actions'; +import { groupById, integerToCurrency } from 'loot-core/shared/util'; import * as monthUtils from 'loot-core/src/shared/months'; import { isPreviewId } from 'loot-core/src/shared/transactions'; +import { useAccounts } from '../../../hooks/useAccounts'; +import { useCategories } from '../../../hooks/useCategories'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { usePayees } from '../../../hooks/usePayees'; +import { + useSelectedDispatch, + useSelectedItems, +} from '../../../hooks/useSelected'; +import { useTransactionBatchActions } from '../../../hooks/useTransactionBatchActions'; +import { useUndo } from '../../../hooks/useUndo'; import { AnimatedLoading } from '../../../icons/AnimatedLoading'; -import { theme } from '../../../style'; +import { SvgDelete } from '../../../icons/v0'; +import { SvgDotsHorizontalTriple } from '../../../icons/v1'; +import { styles, theme } from '../../../style'; +import { Button } from '../../common/Button'; +import { Menu } from '../../common/Menu'; +import { Popover } from '../../common/Popover'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; +import { FloatingActionBar } from '../FloatingActionBar'; import { ListBox } from './ListBox'; import { Transaction } from './Transaction'; +const NOTIFICATION_BOTTOM_INSET = 75; + export function TransactionList({ isLoading, transactions, isNewTransaction, - onSelect, + onOpenTransaction, scrollProps = {}, onLoadMore, }) { @@ -49,6 +76,19 @@ export function TransactionList({ return sections; }, [transactions]); + const dispatchSelected = useSelectedDispatch(); + const selectedTransactions = useSelectedItems(); + + const onTransactionPress = (transaction, isLongPress = false) => { + const isPreview = isPreviewId(transaction.id); + + if (!isPreview && (isLongPress || selectedTransactions.size > 0)) { + dispatchSelected({ type: 'select', id: transaction.id }); + } else { + onOpenTransaction(transaction); + } + }; + if (isLoading) { return ( <View @@ -109,8 +149,10 @@ export function TransactionList({ > <Transaction transaction={transaction} - added={isNewTransaction(transaction.id)} - onSelect={onSelect} + isAdded={isNewTransaction(transaction.id)} + isSelected={selectedTransactions.has(transaction.id)} + onPress={trans => onTransactionPress(trans)} + onLongPress={trans => onTransactionPress(trans, true)} /> </Item> ); @@ -119,6 +161,329 @@ export function TransactionList({ ); })} </ListBox> + {selectedTransactions.size > 0 && ( + <SelectedTransactionsFloatingActionBar transactions={transactions} /> + )} </> ); } + +function SelectedTransactionsFloatingActionBar({ transactions, style }) { + const editMenuTriggerRef = useRef(null); + const [isEditMenuOpen, setIsEditMenuOpen] = useState(false); + const moreOptionsMenuTriggerRef = useRef(null); + const [isMoreOptionsMenuOpen, setIsMoreOptionsMenuOpen] = useState(false); + const getMenuItemStyle = useCallback( + item => ({ + ...styles.mobileMenuItem, + color: theme.mobileHeaderText, + ...(item.name === 'delete' && { color: theme.errorTextMenu }), + }), + [], + ); + const selectedTransactions = useSelectedItems(); + const selectedTransactionsArray = Array.from(selectedTransactions); + const dispatchSelected = useSelectedDispatch(); + + const buttonProps = useMemo( + () => ({ + style: { + ...styles.mobileMenuItem, + color: 'currentColor', + height: styles.mobileMinHeight, + }, + activeStyle: { + color: 'currentColor', + }, + hoveredStyle: { + color: 'currentColor', + }, + }), + [], + ); + + const allTransactionsAreLinked = useMemo(() => { + return transactions + .filter(t => selectedTransactions.has(t.id)) + .every(t => t.schedule); + }, [transactions, selectedTransactions]); + + const isMoreThanOne = selectedTransactions.size > 1; + + const { showUndoNotification } = useUndo(); + const { + onBatchEdit, + onBatchDuplicate, + onBatchDelete, + onBatchLinkSchedule, + onBatchUnlinkSchedule, + } = useTransactionBatchActions(); + + const navigate = useNavigate(); + const accounts = useAccounts(); + const accountsById = useMemo(() => groupById(accounts), [accounts]); + + const payees = usePayees(); + const payeesById = useMemo(() => groupById(payees), [payees]); + + const { list: categories } = useCategories(); + const categoriesById = useMemo(() => groupById(categories), [categories]); + + const dispatch = useDispatch(); + useEffect(() => { + dispatch(setNotificationInset({ bottom: NOTIFICATION_BOTTOM_INSET })); + return () => { + dispatch(setNotificationInset(null)); + }; + }, [dispatch]); + + return ( + <FloatingActionBar style={style}> + <View + style={{ + flex: 1, + padding: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }} + > + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + }} + > + <Button + type="bare" + {...buttonProps} + style={{ ...buttonProps.style, marginRight: 4 }} + onClick={() => { + if (selectedTransactions.size > 0) { + dispatchSelected({ type: 'select-none' }); + } + }} + > + <SvgDelete width={10} height={10} /> + </Button> + <Text style={styles.mediumText}> + {selectedTransactions.size}{' '} + {isMoreThanOne ? 'transactions' : 'transaction'} selected + </Text> + </View> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + }} + > + <Button + type="bare" + ref={editMenuTriggerRef} + aria-label="Edit fields" + onClick={() => { + setIsEditMenuOpen(true); + }} + {...buttonProps} + > + Edit + </Button> + + <Popover + triggerRef={editMenuTriggerRef} + isOpen={isEditMenuOpen} + onOpenChange={() => setIsEditMenuOpen(false)} + style={{ width: 200 }} + > + <Menu + getItemStyle={getMenuItemStyle} + style={{ backgroundColor: theme.floatingActionBarBackground }} + onMenuSelect={name => { + onBatchEdit?.({ + name, + ids: selectedTransactionsArray, + onSuccess: (ids, name, value, mode) => { + let displayValue = value; + switch (name) { + case 'account': + displayValue = accountsById[value]?.name ?? value; + break; + case 'category': + displayValue = categoriesById[value]?.name ?? value; + break; + case 'payee': + displayValue = payeesById[value]?.name ?? value; + break; + case 'amount': + displayValue = integerToCurrency(value); + break; + case 'notes': + displayValue = `${mode} with ${value}`; + break; + default: + displayValue = value; + break; + } + + showUndoNotification({ + message: `Successfully updated ${name} of ${ids.length} transaction${ids.length > 1 ? 's' : ''} to [${displayValue}](#${displayValue}).`, + messageActions: { + [String(displayValue)]: () => { + switch (name) { + case 'account': + navigate(`/accounts/${value}`); + break; + case 'category': + navigate(`/categories/${value}`); + break; + case 'payee': + navigate(`/payees`); + break; + default: + break; + } + }, + }, + }); + }, + }); + setIsEditMenuOpen(false); + }} + items={[ + // Add support later on. + // Pikaday doesn't play well will mobile. + // We should consider switching to react-aria date picker. + // { + // name: 'date', + // text: 'Date', + // }, + { + name: 'account', + text: 'Account', + }, + { + name: 'payee', + text: 'Payee', + }, + { + name: 'notes', + text: 'Notes', + }, + { + name: 'category', + text: 'Category', + }, + // Add support later on until we have more user friendly amount input modal. + // { + // name: 'amount', + // text: 'Amount', + // }, + { + name: 'cleared', + text: 'Cleared', + }, + ]} + /> + </Popover> + + <Button + type="bare" + ref={moreOptionsMenuTriggerRef} + aria-label="More options" + onClick={() => { + setIsMoreOptionsMenuOpen(true); + }} + {...buttonProps} + > + <SvgDotsHorizontalTriple + width={16} + height={16} + style={{ color: 'currentColor' }} + /> + </Button> + + <Popover + triggerRef={moreOptionsMenuTriggerRef} + isOpen={isMoreOptionsMenuOpen} + onOpenChange={() => setIsMoreOptionsMenuOpen(false)} + style={{ width: 200 }} + > + <Menu + getItemStyle={getMenuItemStyle} + style={{ backgroundColor: theme.floatingActionBarBackground }} + onMenuSelect={type => { + if (type === 'duplicate') { + onBatchDuplicate?.({ + ids: selectedTransactionsArray, + onSuccess: ids => { + showUndoNotification({ + message: `Successfully duplicated ${ids.length} transaction${ids.length > 1 ? 's' : ''}.`, + }); + }, + }); + } else if (type === 'link-schedule') { + onBatchLinkSchedule?.({ + ids: selectedTransactionsArray, + onSuccess: (ids, schedule) => { + // TODO: When schedule becomes available in mobile, update undo notification message + // with `messageActions` to open the schedule when the schedule name is clicked. + showUndoNotification({ + message: `Successfully linked ${ids.length} transaction${ids.length > 1 ? 's' : ''} to ${schedule.name}.`, + }); + }, + }); + } else if (type === 'unlink-schedule') { + onBatchUnlinkSchedule?.({ + ids: selectedTransactionsArray, + onSuccess: ids => { + showUndoNotification({ + message: `Successfully unlinked ${ids.length} transaction${ids.length > 1 ? 's' : ''} from their respective schedules.`, + }); + }, + }); + } else if (type === 'delete') { + onBatchDelete?.({ + ids: selectedTransactionsArray, + onSuccess: ids => { + showUndoNotification({ + type: 'warning', + message: `Successfully deleted ${ids.length} transaction${ids.length > 1 ? 's' : ''}.`, + }); + }, + }); + } + setIsMoreOptionsMenuOpen(false); + }} + items={[ + { + name: 'duplicate', + text: 'Duplicate', + }, + ...(allTransactionsAreLinked + ? [ + { + name: 'unlink-schedule', + text: 'Unlink schedule', + }, + ] + : [ + { + name: 'link-schedule', + text: 'Link schedule', + }, + ]), + { + name: 'delete', + text: 'Delete', + }, + ]} + /> + </Popover> + </View> + </View> + </FloatingActionBar> + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx index f29994ddc..24ce42a6d 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { useSelector } from 'react-redux'; +import { SelectedProvider, useSelected } from '../../../hooks/useSelected'; import { SvgSearchAlternate } from '../../../icons/v2'; import { styles, theme } from '../../../style'; import { InputWithContent } from '../../common/InputWithContent'; @@ -64,7 +65,7 @@ export function TransactionListWithBalances({ searchPlaceholder = 'Search...', onSearch, onLoadMore, - onSelectTransaction, + onOpenTransaction, onRefresh, }) { const newTransactions = useSelector(state => state.queries.newTransactions); @@ -74,9 +75,10 @@ export function TransactionListWithBalances({ }; const unclearedAmount = useSheetValue(balanceUncleared); + const selectedInst = useSelected('transactions', transactions); return ( - <> + <SelectedProvider instance={selectedInst}> <View style={{ flexShrink: 0, @@ -159,9 +161,9 @@ export function TransactionListWithBalances({ transactions={transactions} isNewTransaction={isNewTransaction} onLoadMore={onLoadMore} - onSelect={onSelectTransaction} + onOpenTransaction={onOpenTransaction} /> </PullToRefresh> - </> + </SelectedProvider> ); } diff --git a/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx b/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx index 3c1ceaba0..26f2ecaf2 100644 --- a/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx +++ b/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx @@ -8,10 +8,12 @@ import { Paragraph } from '../common/Paragraph'; import { View } from '../common/View'; type ConfirmTransactionDeleteProps = { + message?: string; onConfirm: () => void; }; export function ConfirmTransactionDelete({ + message = 'Are you sure you want to delete the transaction?', onConfirm, }: ConfirmTransactionDeleteProps) { const { isNarrowWidth } = useResponsive(); @@ -30,9 +32,7 @@ export function ConfirmTransactionDelete({ rightContent={<ModalCloseButton onClick={close} />} /> <View style={{ lineHeight: 1.5 }}> - <Paragraph> - Are you sure you want to delete the transaction? - </Paragraph> + <Paragraph>{message}</Paragraph> <View style={{ flexDirection: 'row', diff --git a/packages/desktop-client/src/components/payees/ManagePayees.jsx b/packages/desktop-client/src/components/payees/ManagePayees.jsx index bc0ce9df8..2843bc644 100644 --- a/packages/desktop-client/src/components/payees/ManagePayees.jsx +++ b/packages/desktop-client/src/components/payees/ManagePayees.jsx @@ -59,7 +59,9 @@ function PayeeTableHeader() { exposed={true} focused={false} selected={selectedItems.size > 0} - onSelect={e => dispatchSelected({ type: 'select-all', event: e })} + onSelect={e => + dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) + } /> <Cell value="Name" width="flex" /> </TableHeader> diff --git a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx index 657385c48..637a63da7 100644 --- a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx +++ b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx @@ -130,7 +130,11 @@ export const PayeeTableRow = memo( focused={focusedField === 'select'} selected={selected} onSelect={e => { - dispatchSelected({ type: 'select', id: payee.id, event: e }); + dispatchSelected({ + type: 'select', + id: payee.id, + isRangeSelect: e.shiftKey, + }); }} /> <CustomCell diff --git a/packages/desktop-client/src/components/rules/RuleRow.tsx b/packages/desktop-client/src/components/rules/RuleRow.tsx index 913a6b045..c5c17a510 100644 --- a/packages/desktop-client/src/components/rules/RuleRow.tsx +++ b/packages/desktop-client/src/components/rules/RuleRow.tsx @@ -64,7 +64,11 @@ export const RuleRow = memo( exposed={hovered || selected} focused={true} onSelect={e => { - dispatchSelected({ type: 'select', id: rule.id, event: e }); + dispatchSelected({ + type: 'select', + id: rule.id, + isRangeSelect: e.shiftKey, + }); }} selected={selected} /> diff --git a/packages/desktop-client/src/components/rules/RulesHeader.tsx b/packages/desktop-client/src/components/rules/RulesHeader.tsx index 40ac46cf0..01c61512b 100644 --- a/packages/desktop-client/src/components/rules/RulesHeader.tsx +++ b/packages/desktop-client/src/components/rules/RulesHeader.tsx @@ -13,7 +13,9 @@ export function RulesHeader() { exposed={true} focused={false} selected={selectedItems.size > 0} - onSelect={e => dispatchSelected({ type: 'select-all', event: e })} + onSelect={e => + dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) + } /> <Cell value="Stage" width={50} /> <Cell value="Rule" width="flex" /> diff --git a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx index 745d2d6d5..31d7fc10a 100644 --- a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx +++ b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx @@ -49,7 +49,11 @@ function DiscoverSchedulesTable({ height={ROW_HEIGHT} inset={15} onClick={e => { - dispatchSelected({ type: 'select', id: item.id, event: e }); + dispatchSelected({ + type: 'select', + id: item.id, + isRangeSelect: e.shiftKey, + }); }} style={{ borderColor: selected ? theme.tableBorderSelected : theme.tableBorder, @@ -71,7 +75,11 @@ function DiscoverSchedulesTable({ focused={false} selected={selected} onSelect={e => { - dispatchSelected({ type: 'select', id: item.id, event: e }); + dispatchSelected({ + type: 'select', + id: item.id, + isRangeSelect: e.shiftKey, + }); }} /> <Field width="flex"> @@ -95,7 +103,9 @@ function DiscoverSchedulesTable({ exposed={!loading} focused={false} selected={selectedItems.size > 0} - onSelect={e => dispatchSelected({ type: 'select-all', event: e })} + onSelect={e => + dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) + } /> <Field width="flex">Payee</Field> <Field width="flex">Account</Field> @@ -114,7 +124,7 @@ function DiscoverSchedulesTable({ }} items={schedules} loading={loading} - isSelected={id => selectedItems.has(id)} + isSelected={id => selectedItems.has(String(id))} renderItem={renderItem} renderEmpty="No schedules found" /> diff --git a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx index a5220a9f7..74d22edd7 100644 --- a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx +++ b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx @@ -6,7 +6,10 @@ import { pushModal } from 'loot-core/client/actions'; import { useSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { send } from 'loot-core/src/platform/client/fetch'; import { type Query } from 'loot-core/src/shared/query'; -import { type TransactionEntity } from 'loot-core/src/types/models'; +import { + type ScheduleEntity, + type TransactionEntity, +} from 'loot-core/src/types/models'; import { SvgAdd } from '../../icons/v0'; import { Button } from '../common/Button2'; @@ -21,13 +24,15 @@ export function ScheduleLink({ transactionIds: ids, getTransaction, accountName, + onScheduleLinked, }: { transactionIds: string[]; getTransaction: (transactionId: string) => TransactionEntity; - accountName: string; + accountName?: string; + onScheduleLinked?: (schedule: ScheduleEntity) => void; }) { const dispatch = useDispatch(); - const [filter, setFilter] = useState(accountName); + const [filter, setFilter] = useState(accountName || ''); const scheduleData = useSchedules({ transform: useCallback((q: Query) => q.filter({ completed: false }), []), @@ -45,6 +50,7 @@ export function ScheduleLink({ await send('transactions-batch-update', { updated: ids.map(id => ({ id, schedule: scheduleId })), }); + onScheduleLinked?.(schedules.find(s => s.id === scheduleId)); } } diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx index ad9cded8a..88ca81ac6 100644 --- a/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx +++ b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx @@ -12,13 +12,13 @@ import { Menu } from '../common/Menu'; import { SelectedItemsButton } from '../table'; export function SelectedTransactionsButton({ - account, getTransaction, onShow, onDuplicate, onDelete, onEdit, - onUnlink, + onLinkSchedule, + onUnlinkSchedule, onCreateRule, onSetTransfer, onScheduleAction, @@ -115,16 +115,6 @@ export function SelectedTransactionsButton({ return areNoReconciledTransactions && areAllSplitTransactions; }, [selectedIds, types, getTransaction]); - function onLinkSchedule() { - dispatch( - pushModal('schedule-link', { - transactionIds: selectedIds, - getTransaction, - accountName: account?.name ?? '', - }), - ); - } - function onViewSchedule() { const firstId = selectedIds[0]; let scheduleId; @@ -285,10 +275,10 @@ export function SelectedTransactionsButton({ onViewSchedule(); break; case 'link-schedule': - onLinkSchedule(); + onLinkSchedule(selectedIds); break; case 'unlink-schedule': - onUnlink(selectedIds); + onUnlinkSchedule(selectedIds); break; case 'create-rule': onCreateRule(selectedIds); diff --git a/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx b/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx index 736df89d2..a62335376 100644 --- a/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx @@ -54,7 +54,11 @@ const TransactionRow = memo(function TransactionRow({ exposed={true} focused={false} onSelect={e => { - dispatchSelected({ type: 'select', id: transaction.id, event: e }); + dispatchSelected({ + type: 'select', + id: transaction.id, + isRangeSelect: e.shiftKey, + }); }} selected={selected} /> @@ -182,7 +186,12 @@ export function SimpleTransactionsTable({ focused={false} selected={selectedItems.size > 0} width={20} - onSelect={e => dispatchSelected({ type: 'select-all', event: e })} + onSelect={e => + dispatchSelected({ + type: 'select-all', + isRangeSelect: e.shiftKey, + }) + } /> {fields.map((field, i) => { switch (field) { diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index 34aa417ae..21dc4e0e0 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -208,7 +208,9 @@ const TransactionHeader = memo( borderTopWidth: 0, borderBottomWidth: 0, }} - onSelect={e => dispatchSelected({ type: 'select-all', event: e })} + onSelect={e => + dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) + } /> <HeaderCell value="Date" @@ -1115,7 +1117,11 @@ const Transaction = memo(function Transaction({ }} focused={focusedField === 'select'} onSelect={e => { - dispatchSelected({ type: 'select', id: transaction.id, event: e }); + dispatchSelected({ + type: 'select', + id: transaction.id, + isRangeSelect: e.shiftKey, + }); }} onEdit={() => onEdit(id, 'select')} selected={selected} diff --git a/packages/desktop-client/src/hooks/useSelected.tsx b/packages/desktop-client/src/hooks/useSelected.tsx index 1b38189e6..15c944e60 100644 --- a/packages/desktop-client/src/hooks/useSelected.tsx +++ b/packages/desktop-client/src/hooks/useSelected.tsx @@ -8,7 +8,6 @@ import React, { useRef, type Dispatch, type ReactElement, - type MouseEvent, } from 'react'; import { useSelector } from 'react-redux'; @@ -16,7 +15,6 @@ import { type State } from 'loot-core/src/client/state-types'; import { listen } from 'loot-core/src/platform/client/fetch'; import * as undo from 'loot-core/src/platform/client/undo'; import { type UndoState } from 'loot-core/src/server/undo'; -import { isNonProductionEnvironment } from 'loot-core/src/shared/environment'; type Range<T> = { start: T; end: T | null }; type Item = { id: string }; @@ -35,20 +33,20 @@ type SelectedState = { selectedItems: Set<string>; }; -type WithOptionalMouseEvent = { - event?: MouseEvent; -}; type SelectAction = { type: 'select'; id: string; -} & WithOptionalMouseEvent; + isRangeSelect?: boolean; +}; type SelectNoneAction = { type: 'select-none'; -} & WithOptionalMouseEvent; + isRangeSelect?: boolean; +}; type SelectAllAction = { type: 'select-all'; ids?: string[]; -} & WithOptionalMouseEvent; + isRangeSelect?: boolean; +}; type Actions = SelectAction | SelectNoneAction | SelectAllAction; @@ -64,9 +62,9 @@ export function useSelected<T extends Item>( case 'select': { const { selectedRange } = state; const selectedItems = new Set(state.selectedItems); - const { id, event } = action; + const { id, isRangeSelect } = action; - if (event.shiftKey && selectedRange) { + if (isRangeSelect && selectedRange) { const idx = items.findIndex(p => p.id === id); const startIdx = items.findIndex(p => p.id === selectedRange.start); const endIdx = items.findIndex(p => p.id === selectedRange.end); @@ -275,24 +273,24 @@ export function SelectedProvider<T extends Item>({ const dispatch = useCallback( async (action: Actions) => { - if (!action.event && isNonProductionEnvironment()) { - throw new Error('SelectedDispatch actions must have an event'); - } if (action.type === 'select-all') { if (latestItems.current && latestItems.current.size > 0) { return instance.dispatch({ type: 'select-none', - event: action.event, + isRangeSelect: action.isRangeSelect, }); } else { if (fetchAllIds) { return instance.dispatch({ type: 'select-all', ids: await fetchAllIds(), - event: action.event, + isRangeSelect: action.isRangeSelect, }); } - return instance.dispatch({ type: 'select-all', event: action.event }); + return instance.dispatch({ + type: 'select-all', + isRangeSelect: action.isRangeSelect, + }); } } return instance.dispatch(action); diff --git a/packages/desktop-client/src/hooks/useTransactionBatchActions.ts b/packages/desktop-client/src/hooks/useTransactionBatchActions.ts new file mode 100644 index 000000000..a49c1efbf --- /dev/null +++ b/packages/desktop-client/src/hooks/useTransactionBatchActions.ts @@ -0,0 +1,418 @@ +import { useDispatch } from 'react-redux'; + +import { pushModal } from 'loot-core/client/actions'; +import { runQuery } from 'loot-core/client/query-helpers'; +import { send } from 'loot-core/platform/client/fetch'; +import { q } from 'loot-core/shared/query'; +import { + deleteTransaction, + realizeTempTransactions, + ungroupTransaction, + ungroupTransactions, + updateTransaction, +} from 'loot-core/shared/transactions'; +import { applyChanges, type Diff } from 'loot-core/shared/util'; +import * as monthUtils from 'loot-core/src/shared/months'; +import { + type AccountEntity, + type ScheduleEntity, + type TransactionEntity, +} from 'loot-core/types/models'; + +type BatchEditProps = { + name: keyof TransactionEntity; + ids: Array<TransactionEntity['id']>; + onSuccess?: ( + ids: Array<TransactionEntity['id']>, + name: keyof TransactionEntity, + value: string | number | boolean | null, + mode: 'prepend' | 'append' | 'replace' | null | undefined, + ) => void; +}; + +type BatchDuplicateProps = { + ids: Array<TransactionEntity['id']>; + onSuccess?: (ids: Array<TransactionEntity['id']>) => void; +}; + +type BatchDeleteProps = { + ids: Array<TransactionEntity['id']>; + onSuccess?: (ids: Array<TransactionEntity['id']>) => void; +}; + +type BatchLinkScheduleProps = { + ids: Array<TransactionEntity['id']>; + account?: AccountEntity; + onSuccess?: ( + ids: Array<TransactionEntity['id']>, + schedule: ScheduleEntity, + ) => void; +}; + +type BatchUnlinkScheduleProps = { + ids: Array<TransactionEntity['id']>; + onSuccess?: (ids: Array<TransactionEntity['id']>) => void; +}; + +export function useTransactionBatchActions() { + const dispatch = useDispatch(); + + const onBatchEdit = async ({ name, ids, onSuccess }: BatchEditProps) => { + const { data } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids } }) + .select('*') + .options({ splits: 'grouped' }), + ); + const transactions = ungroupTransactions(data as TransactionEntity[]); + + const onChange = async ( + name: keyof TransactionEntity, + value: string | number | boolean | null, + mode?: 'prepend' | 'append' | 'replace' | null | undefined, + ) => { + let transactionsToChange = transactions; + + value = value === null ? '' : value; + const changes: Diff<TransactionEntity> = { + added: [], + deleted: [], + updated: [], + }; + + // Cleared is a special case right now + if (name === 'cleared') { + // Clear them if any are uncleared, otherwise unclear them + value = !!transactionsToChange.find(t => !t.cleared); + } + + const idSet = new Set(ids); + + transactionsToChange.forEach(trans => { + if (name === 'cleared' && trans.reconciled) { + // Skip transactions that are reconciled. Don't want to set them as + // uncleared. + return; + } + + if (!idSet.has(trans.id)) { + // Skip transactions which aren't actually selected, since the query + // above also retrieves the siblings & parent of any selected splits. + return; + } + + let valueToSet = value; + + if (name === 'notes') { + if (mode === 'prepend') { + valueToSet = + trans.notes === null ? value : value + ' ' + trans.notes; + } else if (mode === 'append') { + valueToSet = + trans.notes === null ? value : trans.notes + ' ' + value; + } else if (mode === 'replace') { + valueToSet = value; + } + } + const transaction = { + ...trans, + [name]: valueToSet, + }; + + if (name === 'account' && trans.account !== value) { + transaction.reconciled = false; + } + + const { diff } = updateTransaction(transactionsToChange, transaction); + + // TODO: We need to keep an updated list of transactions so + // the logic in `updateTransaction`, particularly about + // updating split transactions, works. This isn't ideal and we + // should figure something else out + transactionsToChange = applyChanges<TransactionEntity>( + diff, + transactionsToChange, + ); + + changes.deleted = changes.deleted + ? changes.deleted.concat(diff.deleted) + : diff.deleted; + changes.updated = changes.updated + ? changes.updated.concat(diff.updated) + : diff.updated; + changes.added = changes.added + ? changes.added.concat(diff.added) + : diff.added; + }); + + await send('transactions-batch-update', changes); + + onSuccess?.(ids, name, value, mode); + }; + + const pushPayeeAutocompleteModal = () => { + dispatch( + pushModal('payee-autocomplete', { + onSelect: payeeId => onChange(name, payeeId), + }), + ); + }; + + const pushAccountAutocompleteModal = () => { + dispatch( + pushModal('account-autocomplete', { + onSelect: accountId => onChange(name, accountId), + }), + ); + }; + + const pushEditField = () => { + if (name !== 'date' && name !== 'amount' && name !== 'notes') { + return; + } + + dispatch( + pushModal('edit-field', { + name, + onSubmit: (name, value, mode) => onChange(name, value, mode), + }), + ); + }; + + const pushCategoryAutocompleteModal = () => { + // Only show balances when all selected transaction are in the same month. + const transactionMonth = transactions[0]?.date + ? monthUtils.monthFromDate(transactions[0]?.date) + : null; + const transactionsHaveSameMonth = + transactionMonth && + transactions.every( + t => monthUtils.monthFromDate(t.date) === transactionMonth, + ); + dispatch( + pushModal('category-autocomplete', { + month: transactionsHaveSameMonth ? transactionMonth : undefined, + onSelect: categoryId => onChange(name, categoryId), + }), + ); + }; + + if ( + name === 'amount' || + name === 'payee' || + name === 'account' || + name === 'date' + ) { + const reconciledTransactions = transactions.filter(t => t.reconciled); + if (reconciledTransactions.length > 0) { + dispatch( + pushModal('confirm-transaction-edit', { + onConfirm: () => { + if (name === 'payee') { + pushPayeeAutocompleteModal(); + } else if (name === 'account') { + pushAccountAutocompleteModal(); + } else { + pushEditField(); + } + }, + confirmReason: 'batchEditWithReconciled', + }), + ); + return; + } + } + + if (name === 'cleared') { + // Cleared just toggles it on/off and it depends on the data + // loaded. Need to clean this up in the future. + onChange('cleared', null); + } else if (name === 'category') { + pushCategoryAutocompleteModal(); + } else if (name === 'payee') { + pushPayeeAutocompleteModal(); + } else if (name === 'account') { + pushAccountAutocompleteModal(); + } else { + pushEditField(); + } + }; + + const onBatchDuplicate = async ({ ids, onSuccess }: BatchDuplicateProps) => { + const onConfirmDuplicate = async (ids: Array<TransactionEntity['id']>) => { + const { data } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids } }) + .select('*') + .options({ splits: 'grouped' }), + ); + + const transactions = data as TransactionEntity[]; + + const changes = { + added: transactions + .reduce( + ( + newTransactions: TransactionEntity[], + trans: TransactionEntity, + ) => { + return newTransactions.concat( + realizeTempTransactions(ungroupTransaction(trans)), + ); + }, + [], + ) + .map(({ sort_order, ...trans }: TransactionEntity) => ({ ...trans })), + }; + + await send('transactions-batch-update', changes); + + onSuccess?.(ids); + }; + + await checkForReconciledTransactions( + ids, + 'batchDuplicateWithReconciled', + onConfirmDuplicate, + ); + }; + + const onBatchDelete = async ({ ids, onSuccess }: BatchDeleteProps) => { + const onConfirmDelete = (ids: Array<TransactionEntity['id']>) => { + dispatch( + pushModal('confirm-transaction-delete', { + message: + ids.length > 1 + ? `Are you sure you want to delete these ${ids.length} transaction${ids.length > 1 ? 's' : ''}?` + : undefined, + onConfirm: async () => { + const { data } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids } }) + .select('*') + .options({ splits: 'grouped' }), + ); + let transactions = ungroupTransactions(data as TransactionEntity[]); + + const idSet = new Set(ids); + const changes: Diff<TransactionEntity> = { + added: [], + deleted: [], + updated: [], + }; + + transactions.forEach(trans => { + const parentId = trans.parent_id; + + // First, check if we're actually deleting this transaction by + // checking `idSet`. Then, we don't need to do anything if it's + // a child transaction and the parent is already being deleted + if (!idSet.has(trans.id) || (parentId && idSet.has(parentId))) { + return; + } + + const { diff } = deleteTransaction(transactions, trans.id); + + // TODO: We need to keep an updated list of transactions so + // the logic in `updateTransaction`, particularly about + // updating split transactions, works. This isn't ideal and we + // should figure something else out + transactions = applyChanges<TransactionEntity>( + diff, + transactions, + ); + + changes.deleted = diff.deleted + ? changes.deleted.concat(diff.deleted) + : diff.deleted; + changes.updated = diff.updated + ? changes.updated.concat(diff.updated) + : diff.updated; + }); + + await send('transactions-batch-update', changes); + onSuccess?.(ids); + }, + }), + ); + }; + + await checkForReconciledTransactions( + ids, + 'batchDeleteWithReconciled', + onConfirmDelete, + ); + }; + + const onBatchLinkSchedule = async ({ + ids, + account, + onSuccess, + }: BatchLinkScheduleProps) => { + const { data: transactions } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids } }) + .select('*') + .options({ splits: 'grouped' }), + ); + + dispatch( + pushModal('schedule-link', { + transactionIds: ids, + getTransaction: (id: TransactionEntity['id']) => + transactions.find((t: TransactionEntity) => t.id === id), + accountName: account?.name ?? '', + onScheduleLinked: schedule => { + onSuccess?.(ids, schedule); + }, + }), + ); + }; + + const onBatchUnlinkSchedule = async ({ + ids, + onSuccess, + }: BatchUnlinkScheduleProps) => { + const changes = { + updated: ids.map( + id => ({ id, schedule: null }) as unknown as Partial<TransactionEntity>, + ), + }; + await send('transactions-batch-update', changes); + onSuccess?.(ids); + }; + + const checkForReconciledTransactions = async ( + ids: Array<TransactionEntity['id']>, + confirmReason: string, + onConfirm: (ids: Array<TransactionEntity['id']>) => void, + ) => { + const { data } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids }, reconciled: true }) + .select('*') + .options({ splits: 'grouped' }), + ); + const transactions = ungroupTransactions(data as TransactionEntity[]); + if (transactions.length > 0) { + dispatch( + pushModal('confirm-transaction-edit', { + onConfirm: () => { + onConfirm(ids); + }, + confirmReason, + }), + ); + } else { + onConfirm(ids); + } + }; + + return { + onBatchEdit, + onBatchDuplicate, + onBatchDelete, + onBatchLinkSchedule, + onBatchUnlinkSchedule, + }; +} diff --git a/packages/desktop-client/src/hooks/useUndo.ts b/packages/desktop-client/src/hooks/useUndo.ts new file mode 100644 index 000000000..4101f668c --- /dev/null +++ b/packages/desktop-client/src/hooks/useUndo.ts @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { undo, redo, addNotification } from 'loot-core/client/actions'; +import { type Notification } from 'loot-core/client/state-types/notifications'; + +type UndoActions = { + undo: () => void; + redo: () => void; + showUndoNotification: (undoNotification: Notification) => void; + showRedoNotification: (redoNotification: Notification) => void; +}; + +const timeout = 10000; + +export function useUndo(): UndoActions { + const dispatch = useDispatch(); + + const dispatchUndo = useCallback(() => { + dispatch(undo()); + }, [dispatch]); + + const dispatchRedo = useCallback(() => { + dispatch(redo()); + }, [dispatch]); + + const showUndoNotification = useCallback( + (notification: Notification) => { + dispatch( + addNotification({ + type: 'message', + timeout, + button: { + title: 'Undo', + action: dispatchUndo, + }, + ...notification, + }), + ); + }, + [dispatch, dispatchUndo], + ); + + const showRedoNotification = useCallback( + (notificaton: Notification) => { + dispatch( + addNotification({ + type: 'message', + timeout, + button: { + title: 'Redo', + action: dispatchRedo, + }, + ...notificaton, + }), + ); + }, + [dispatch, dispatchRedo], + ); + + return { + undo: dispatchUndo, + redo: dispatchRedo, + showUndoNotification, + showRedoNotification, + }; +} diff --git a/packages/desktop-client/src/style/themes/dark.ts b/packages/desktop-client/src/style/themes/dark.ts index 722d886ab..fcd19fb4a 100644 --- a/packages/desktop-client/src/style/themes/dark.ts +++ b/packages/desktop-client/src/style/themes/dark.ts @@ -75,6 +75,7 @@ export const mobileNavItem = colorPalette.navy150; export const mobileNavItemSelected = colorPalette.purple400; export const mobileAccountShadow = cardShadow; export const mobileAccountText = colorPalette.blue800; +export const mobileTransactionSelected = colorPalette.purple400; // Mobile view themes (for the top bar) export const mobileViewTheme = mobileHeaderBackground; @@ -201,3 +202,7 @@ export const budgetOtherMonth = colorPalette.navy900; export const budgetCurrentMonth = tableBackground; export const budgetHeaderOtherMonth = colorPalette.navy800; export const budgetHeaderCurrentMonth = tableHeaderBackground; + +export const floatingActionBarBackground = colorPalette.purple800; +export const floatingActionBarBorder = floatingActionBarBackground; +export const floatingActionBarText = colorPalette.navy150; diff --git a/packages/desktop-client/src/style/themes/development.ts b/packages/desktop-client/src/style/themes/development.ts index 8a1a1f955..b3a6a6986 100644 --- a/packages/desktop-client/src/style/themes/development.ts +++ b/packages/desktop-client/src/style/themes/development.ts @@ -75,6 +75,7 @@ export const mobileNavItem = colorPalette.gray300; export const mobileNavItemSelected = colorPalette.purple500; export const mobileAccountShadow = colorPalette.navy300; export const mobileAccountText = colorPalette.blue800; +export const mobileTransactionSelected = colorPalette.purple500; // Mobile view themes (for the top bar) export const mobileViewTheme = mobileHeaderBackground; @@ -200,3 +201,7 @@ export const budgetCurrentMonth = tableBackground; export const budgetOtherMonth = colorPalette.gray50; export const budgetHeaderCurrentMonth = budgetOtherMonth; export const budgetHeaderOtherMonth = colorPalette.gray80; + +export const floatingActionBarBackground = colorPalette.purple400; +export const floatingActionBarBorder = floatingActionBarBackground; +export const floatingActionBarText = colorPalette.navy50; diff --git a/packages/desktop-client/src/style/themes/light.ts b/packages/desktop-client/src/style/themes/light.ts index e5ce2e1db..aad174b85 100644 --- a/packages/desktop-client/src/style/themes/light.ts +++ b/packages/desktop-client/src/style/themes/light.ts @@ -77,6 +77,7 @@ export const mobileNavItem = colorPalette.gray300; export const mobileNavItemSelected = colorPalette.purple500; export const mobileAccountShadow = colorPalette.navy300; export const mobileAccountText = colorPalette.blue800; +export const mobileTransactionSelected = colorPalette.purple500; // Mobile view themes (for the top bar) export const mobileViewTheme = mobileHeaderBackground; @@ -203,3 +204,7 @@ export const budgetCurrentMonth = tableBackground; export const budgetOtherMonth = colorPalette.gray50; export const budgetHeaderCurrentMonth = budgetOtherMonth; export const budgetHeaderOtherMonth = colorPalette.gray80; + +export const floatingActionBarBackground = colorPalette.purple400; +export const floatingActionBarBorder = floatingActionBarBackground; +export const floatingActionBarText = colorPalette.navy50; diff --git a/packages/desktop-client/src/style/themes/midnight.ts b/packages/desktop-client/src/style/themes/midnight.ts index f19701e6d..8869a2ba5 100644 --- a/packages/desktop-client/src/style/themes/midnight.ts +++ b/packages/desktop-client/src/style/themes/midnight.ts @@ -76,6 +76,7 @@ export const mobileNavItem = colorPalette.gray150; export const mobileNavItemSelected = colorPalette.purple200; export const mobileAccountShadow = cardShadow; export const mobileAccountText = colorPalette.blue800; +export const mobileTransactionSelected = colorPalette.purple300; // Mobile view themes (for the top bar) export const mobileViewTheme = mobileHeaderBackground; @@ -203,3 +204,7 @@ export const budgetOtherMonth = colorPalette.gray700; export const budgetCurrentMonth = tableBackground; export const budgetHeaderOtherMonth = colorPalette.gray800; export const budgetHeaderCurrentMonth = tableHeaderBackground; + +export const floatingActionBarBackground = colorPalette.gray900; +export const floatingActionBarBorder = colorPalette.purple300; +export const floatingActionBarText = colorPalette.purple200; diff --git a/packages/loot-core/src/client/actions/notifications.ts b/packages/loot-core/src/client/actions/notifications.ts index 981426b0e..9c0221548 100644 --- a/packages/loot-core/src/client/actions/notifications.ts +++ b/packages/loot-core/src/client/actions/notifications.ts @@ -6,10 +6,11 @@ import type { AddNotificationAction, RemoveNotificationAction, Notification, + SetNotificationInsetAction, } from '../state-types/notifications'; export function addNotification( - notification: Omit<Notification, 'id'> & { id?: string }, + notification: Notification, ): AddNotificationAction { return { type: constants.ADD_NOTIFICATION, @@ -36,3 +37,12 @@ export function removeNotification(id: string): RemoveNotificationAction { id, }; } + +export function setNotificationInset( + inset?: SetNotificationInsetAction['inset'] | null, +): SetNotificationInsetAction { + return { + type: constants.SET_NOTIFICATION_INSET, + inset: inset ? inset : {}, + }; +} diff --git a/packages/loot-core/src/client/constants.ts b/packages/loot-core/src/client/constants.ts index 272ca0a9e..e7c59748c 100644 --- a/packages/loot-core/src/client/constants.ts +++ b/packages/loot-core/src/client/constants.ts @@ -22,6 +22,7 @@ export const COLLAPSE_MODALS = 'COLLAPSE_MODALS'; export const POP_MODAL = 'POP_MODAL'; export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'; export const REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION'; +export const SET_NOTIFICATION_INSET = 'SET_NOTIFICATION_INSET'; export const GET_USER_DATA = 'GET_USER_DATA'; export const SET_LAST_UNDO_STATE = 'SET_LAST_UNDO_STATE'; export const SET_LAST_SPLIT_STATE = 'SET_LAST_SPLIT_STATE'; diff --git a/packages/loot-core/src/client/reducers/notifications.ts b/packages/loot-core/src/client/reducers/notifications.ts index 2221c6c59..b1dd868d9 100644 --- a/packages/loot-core/src/client/reducers/notifications.ts +++ b/packages/loot-core/src/client/reducers/notifications.ts @@ -5,6 +5,7 @@ import type { NotificationsState } from '../state-types/notifications'; const initialState = { notifications: [], + inset: {}, }; export function update( @@ -26,6 +27,11 @@ export function update( ...state, notifications: state.notifications.filter(n => n.id !== action.id), }; + case constants.SET_NOTIFICATION_INSET: + return { + ...state, + inset: action.inset, + }; default: } 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 cf801cb2e..078996b3c 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -4,6 +4,7 @@ import type { CategoryEntity, CategoryGroupEntity, GoCardlessToken, + ScheduleEntity, TransactionEntity, } from '../../types/models'; import type { NewRuleEntity, RuleEntity } from '../../types/models/rule'; @@ -93,14 +94,17 @@ type FinanceModals = { }; 'edit-field': { - name: string; - month: string; - onSubmit: (name: string, value: string) => void; - onClose: () => void; + name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>; + onSubmit: ( + name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>, + value: string | number, + mode?: 'prepend' | 'append' | 'replace' | null, + ) => void; + onClose?: () => void; }; 'category-autocomplete': { - categoryGroups: CategoryGroupEntity[]; + categoryGroups?: CategoryGroupEntity[]; onSelect: (categoryId: string, categoryName: string) => void; month?: string; showHiddenCategories?: boolean; @@ -124,7 +128,14 @@ type FinanceModals = { 'schedule-edit': { id: string; transaction?: TransactionEntity } | null; - 'schedule-link': { transactionIds: string[] } | null; + 'schedule-link': { + transactionIds: string[]; + getTransaction: ( + transactionId: TransactionEntity['id'], + ) => TransactionEntity; + accountName?: string; + onScheduleLinked?: (schedule: ScheduleEntity) => void; + }; 'schedules-discover': null; @@ -256,6 +267,7 @@ type FinanceModals = { confirmReason: string; }; 'confirm-transaction-delete': { + message?: string; onConfirm: () => void; }; }; diff --git a/packages/loot-core/src/client/state-types/notifications.d.ts b/packages/loot-core/src/client/state-types/notifications.d.ts index 551916e9f..cebb60b8e 100644 --- a/packages/loot-core/src/client/state-types/notifications.d.ts +++ b/packages/loot-core/src/client/state-types/notifications.d.ts @@ -21,6 +21,12 @@ type NotificationWithId = Notification & { id: string }; export type NotificationsState = { notifications: NotificationWithId[]; + inset?: { + bottom?: number; + top?: number; + right?: number; + left?: number; + }; }; type AddNotificationAction = { @@ -33,6 +39,17 @@ type RemoveNotificationAction = { id: string; }; +type SetNotificationInsetAction = { + type: typeof constants.SET_NOTIFICATION_INSET; + inset: { + bottom?: number; + top?: number; + right?: number; + left?: number; + }; +}; + export type NotificationsActions = | AddNotificationAction - | RemoveNotificationAction; + | RemoveNotificationAction + | SetNotificationInsetAction; diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts index cad37f5aa..303a45843 100644 --- a/packages/loot-core/src/mocks/budget.ts +++ b/packages/loot-core/src/mocks/budget.ts @@ -1,4 +1,6 @@ // @ts-strict-ignore +import { v4 as uuidv4 } from 'uuid'; + import { addTransactions } from '../server/accounts/sync'; import { runQuery as aqlQuery } from '../server/aql'; import * as budgetActions from '../server/budget/actions'; @@ -13,13 +15,13 @@ import type { Handlers } from '../types/handlers'; import type { CategoryGroupEntity, NewCategoryGroupEntity, - NewPayeeEntity, - NewTransactionEntity, + PayeeEntity, + TransactionEntity, } from '../types/models'; import { random } from './random'; -type MockPayeeEntity = NewPayeeEntity & { bill?: boolean }; +type MockPayeeEntity = Partial<PayeeEntity> & { bill?: boolean }; function pickRandom<T>(list: T[]): T { return list[Math.floor(random() * list.length) % list.length]; @@ -119,11 +121,17 @@ async function fillPrimaryChecking( amount = integer(0, random() < 0.05 ? -8000 : -700); } - const transaction: NewTransactionEntity = { + const currentDate = monthUtils.subDays( + monthUtils.currentDay(), + Math.floor(i / 3), + ); + + const transaction: TransactionEntity = { + id: uuidv4(), amount, payee: payee.id, account: account.id, - date: monthUtils.subDays(monthUtils.currentDay(), Math.floor(i / 3)), + date: currentDate, category: category.id, }; transactions.push(transaction); @@ -135,9 +143,24 @@ async function fillPrimaryChecking( ? incomeGroup.categories.find(c => c.name === 'Income').id : pickRandom(expenseCategories).id; transaction.subtransactions = [ - { amount: a, category: pick() }, - { amount: a, category: pick() }, { + id: uuidv4(), + date: currentDate, + account: account.id, + amount: a, + category: pick(), + }, + { + id: uuidv4(), + date: currentDate, + account: account.id, + amount: a, + category: pick(), + }, + { + id: uuidv4(), + date: currentDate, + account: account.id, amount: transaction.amount - a * 2, category: pick(), }, @@ -403,8 +426,9 @@ async function fillOther(handlers, account, payees, groups) { const numTransactions = integer(3, 6); const category = incomeGroup.categories.find(c => c.name === 'Income'); - const transactions: NewTransactionEntity[] = [ + const transactions: TransactionEntity[] = [ { + id: uuidv4(), amount: integer(3250, 3700) * 100 * 100, payee: payees.find(p => p.name === 'Starting Balance').id, account: account.id, @@ -419,6 +443,7 @@ async function fillOther(handlers, account, payees, groups) { const amount = integer(4, 9) * 100 * 100; transactions.push({ + id: uuidv4(), amount, payee: payee.id, account: account.id, @@ -596,7 +621,7 @@ export async function createTestBudget(handlers: Handlers) { } }); - const payees: Array<MockPayeeEntity> = [ + const newPayees: Array<MockPayeeEntity> = [ { name: 'Starting Balance' }, { name: 'Kroger' }, { name: 'Publix' }, @@ -611,10 +636,17 @@ export async function createTestBudget(handlers: Handlers) { { name: 'T-mobile', bill: true }, ]; + const payees: PayeeEntity[] = []; + await runMutator(() => batchMessages(async () => { - for (const payee of payees) { - payee.id = await handlers['payee-create']({ name: payee.name }); + for (const newPayee of newPayees) { + const id = await handlers['payee-create']({ name: newPayee.name }); + payees.push({ + id, + name: newPayee.name, + ...newPayee, + }); } }), ); diff --git a/packages/loot-core/src/server/accounts/transactions.ts b/packages/loot-core/src/server/accounts/transactions.ts index 08fb702a7..a056daac1 100644 --- a/packages/loot-core/src/server/accounts/transactions.ts +++ b/packages/loot-core/src/server/accounts/transactions.ts @@ -1,6 +1,7 @@ // @ts-strict-ignore import * as connection from '../../platform/server/connection'; -import { NewTransactionEntity, TransactionEntity } from '../../types/models'; +import { Diff } from '../../shared/util'; +import { TransactionEntity } from '../../types/models'; import * as db from '../db'; import { incrFetch, whereIn } from '../db/util'; import { batchMessages } from '../sync'; @@ -42,10 +43,7 @@ export async function batchUpdateTransactions({ learnCategories = false, detectOrphanPayees = true, runTransfers = true, -}: { - added?: Array<Partial<NewTransactionEntity | TransactionEntity>>; - deleted?: Array<Partial<NewTransactionEntity | TransactionEntity>>; - updated?: Array<Partial<NewTransactionEntity | TransactionEntity>>; +}: Partial<Diff<TransactionEntity>> & { learnCategories?: boolean; detectOrphanPayees?: boolean; runTransfers?: boolean; diff --git a/packages/loot-core/src/shared/transactions.test.ts b/packages/loot-core/src/shared/transactions.test.ts index 1f81ecadd..7b0c69697 100644 --- a/packages/loot-core/src/shared/transactions.test.ts +++ b/packages/loot-core/src/shared/transactions.test.ts @@ -16,22 +16,7 @@ function makeTransaction(data: Partial<TransactionEntity>): TransactionEntity { id: uuidv4(), amount: 2422, date: '2020-01-05', - account: { - id: 'acc-id-1', - name: 'account-1', - offbudget: 0, - closed: 0, - sort_order: 1, - tombstone: 0, - account_id: null, - bank: null, - mask: null, - official_name: null, - balance_current: null, - balance_available: null, - balance_limit: null, - account_sync_source: null, - }, + account: 'acc-id-1', ...data, }; } diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts index d56389ca3..5572446d9 100644 --- a/packages/loot-core/src/shared/transactions.ts +++ b/packages/loot-core/src/shared/transactions.ts @@ -1,9 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; -import { - type TransactionEntity, - type NewTransactionEntity, -} from '../types/models'; +import { type TransactionEntity } from '../types/models'; import { last, diffItems, applyChanges } from './util'; @@ -35,10 +32,7 @@ function SplitTransactionError(total: number, parent: TransactionEntity) { }; } -type GenericTransactionEntity = - | NewTransactionEntity - | TransactionEntity - | TransactionEntityWithError; +type GenericTransactionEntity = TransactionEntity | TransactionEntityWithError; export function makeChild<T extends GenericTransactionEntity>( parent: T, @@ -140,7 +134,7 @@ export function groupTransaction(split: TransactionEntity[]) { export function ungroupTransaction(split: TransactionEntity | null) { if (split == null) { - return null; + return []; } return ungroupTransactions([split]); } @@ -163,7 +157,11 @@ function replaceTransactions( func: ( transaction: TransactionEntity, ) => TransactionEntity | TransactionEntityWithError | null, -) { +): { + data: TransactionEntity[]; + newTransaction: TransactionEntity | TransactionEntityWithError | null; + diff: ReturnType<typeof diffItems<TransactionEntity>>; +} { const idx = transactions.findIndex(t => t.id === id); const trans = transactions[idx]; const transactionsCopy = [...transactions]; @@ -176,22 +174,26 @@ function replaceTransactions( const parentIndex = findParentIndex(transactions, idx); if (parentIndex == null) { console.log('Cannot find parent index'); - return { data: [], diff: { deleted: [], updated: [] } }; + return { + data: [], + diff: { added: [], deleted: [], updated: [] }, + newTransaction: null, + }; } const split = getSplit(transactions, parentIndex); let grouped = func(groupTransaction(split)); const newSplit = ungroupTransaction(grouped); - let diff; + let diff: ReturnType<typeof diffItems<TransactionEntity>>; if (newSplit == null) { // If everything was deleted, just delete the parent which will // delete everything - diff = { deleted: [{ id: split[0].id }], updated: [] }; + diff = { added: [], deleted: [{ id: split[0].id }], updated: [] }; grouped = { ...split[0], _deleted: true }; transactionsCopy.splice(parentIndex, split.length); } else { - diff = diffItems(split, newSplit); + diff = diffItems<TransactionEntity>(split, newSplit); transactionsCopy.splice(parentIndex, split.length, ...newSplit); } @@ -210,7 +212,7 @@ function replaceTransactions( ...trans, _deleted: true, }, - diff: diffItems([trans], newTrans), + diff: diffItems<TransactionEntity>([trans], newTrans), }; } } @@ -320,16 +322,24 @@ export function splitTransaction( }); } -export function realizeTempTransactions(transactions: TransactionEntity[]) { - const parent = { ...transactions.find(t => !t.is_child), id: uuidv4() }; +export function realizeTempTransactions( + transactions: TransactionEntity[], +): TransactionEntity[] { + const parent = { + ...transactions.find(t => !t.is_child), + id: uuidv4(), + } as TransactionEntity; const children = transactions.filter(t => t.is_child); return [ parent, - ...children.map(child => ({ - ...child, - id: uuidv4(), - parent_id: parent.id, - })), + ...children.map( + child => + ({ + ...child, + id: uuidv4(), + parent_id: parent.id, + }) as TransactionEntity, + ), ]; } diff --git a/packages/loot-core/src/shared/util.ts b/packages/loot-core/src/shared/util.ts index 7a8c9681b..4b46d0d73 100644 --- a/packages/loot-core/src/shared/util.ts +++ b/packages/loot-core/src/shared/util.ts @@ -42,12 +42,14 @@ export function hasFieldsChanged<T extends object>( return changed; } +export type Diff<T extends { id: string }> = { + added: T[]; + updated: Partial<T>[]; + deleted: Partial<T>[]; +}; + export function applyChanges<T extends { id: string }>( - changes: { - added?: T[]; - updated?: T[]; - deleted?: T[]; - }, + changes: Diff<T>, items: T[], ) { items = [...items]; @@ -118,15 +120,18 @@ function _groupById<T extends { id: string }>(data: T[]) { return res; } -export function diffItems<T extends { id: string }>(items: T[], newItems: T[]) { +export function diffItems<T extends { id: string }>( + items: T[], + newItems: T[], +): Diff<T> { const grouped = _groupById(items); const newGrouped = _groupById(newItems); const added: T[] = []; const updated: Partial<T>[] = []; - const deleted = items + const deleted: Partial<T>[] = items .filter(item => !newGrouped.has(item.id)) - .map(item => ({ id: item.id })); + .map(item => ({ id: item.id }) as Partial<T>); newItems.forEach(newItem => { const item = grouped.get(newItem.id); diff --git a/packages/loot-core/src/types/models/category.d.ts b/packages/loot-core/src/types/models/category.d.ts index 9e794fe66..80f6c1214 100644 --- a/packages/loot-core/src/types/models/category.d.ts +++ b/packages/loot-core/src/types/models/category.d.ts @@ -1,8 +1,10 @@ +import { CategoryGroupEntity } from './category-group'; + export interface CategoryEntity { id: string; name: string; is_income?: boolean; - cat_group?: string; + cat_group?: CategoryGroupEntity['id']; sort_order?: number; tombstone?: boolean; hidden?: boolean; diff --git a/packages/loot-core/src/types/models/payee.d.ts b/packages/loot-core/src/types/models/payee.d.ts index 1d95d00c9..f55f04aa4 100644 --- a/packages/loot-core/src/types/models/payee.d.ts +++ b/packages/loot-core/src/types/models/payee.d.ts @@ -1,11 +1,9 @@ -export interface NewPayeeEntity { - id?: string; +import { AccountEntity } from './account'; + +export interface PayeeEntity { + id: string; name: string; - transfer_acct?: string; + transfer_acct?: AccountEntity['id']; favorite?: boolean; tombstone?: boolean; } - -export interface PayeeEntity extends NewPayeeEntity { - id: string; -} diff --git a/packages/loot-core/src/types/models/transaction.d.ts b/packages/loot-core/src/types/models/transaction.d.ts index 93699a59b..9cb097fec 100644 --- a/packages/loot-core/src/types/models/transaction.d.ts +++ b/packages/loot-core/src/types/models/transaction.d.ts @@ -1,40 +1,27 @@ -import type { AccountEntity } from './account'; -import type { CategoryEntity } from './category'; -import type { PayeeEntity } from './payee'; -import type { ScheduleEntity } from './schedule'; +import { AccountEntity } from './account'; +import { CategoryEntity } from './category'; +import { PayeeEntity } from './payee'; +import { ScheduleEntity } from './schedule'; -export interface NewTransactionEntity { - id?: string; +export interface TransactionEntity { + id: string; is_parent?: boolean; is_child?: boolean; - parent_id?: string; - account: string; - category?: string; + parent_id?: TransactionEntity['id']; + account: AccountEntity['id']; + category?: CategoryEntity['id']; amount: number; - payee?: string; + payee?: PayeeEntity['id']; notes?: string; date: string; imported_id?: string; imported_payee?: string; starting_balance_flag?: boolean; - transfer_id?: string; + transfer_id?: TransactionEntity['id']; sort_order?: number; cleared?: boolean; reconciled?: boolean; tombstone?: boolean; - schedule?: string; - subtransactions?: Omit<NewTransactionEntity, 'account' | 'date'>[]; -} - -export interface TransactionEntity - extends Omit< - NewTransactionEntity, - 'account' | 'category' | 'payee' | 'schedule' | 'subtransactions' - > { - id: string; - account: AccountEntity; - category?: CategoryEntity; - payee?: PayeeEntity; - schedule?: ScheduleEntity; + schedule?: ScheduleEntity['id']; subtransactions?: TransactionEntity[]; } diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 3bea94101..300484f7d 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -28,11 +28,8 @@ export interface ServerHandlers { redo: () => Promise<void>; 'transactions-batch-update': ( - arg: Omit< - Parameters<typeof batchUpdateTransactions>[0], - 'detectOrphanPayees' - >, - ) => Promise<Awaited<ReturnType<typeof batchUpdateTransactions>>>; + ...arg: Parameters<typeof batchUpdateTransactions> + ) => ReturnType<typeof batchUpdateTransactions>; 'transaction-add': (transaction) => Promise<EmptyObject>; diff --git a/upcoming-release-notes/2892.md b/upcoming-release-notes/2892.md new file mode 100644 index 000000000..41392a9ce --- /dev/null +++ b/upcoming-release-notes/2892.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [joel-jeremy] +--- + +Long press transactions in mobile account view to reveal action bar with more actions. diff --git a/yarn.lock b/yarn.lock index bf70e0d94..947d418be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -105,6 +105,7 @@ __metadata: promise-retry: "npm:^2.0.1" re-resizable: "npm:^6.9.17" react: "npm:18.2.0" + react-aria: "npm:^3.33.1" react-aria-components: "npm:^1.2.1" react-dnd: "npm:^16.0.1" react-dnd-html5-backend: "npm:^16.0.1" -- GitLab