Convert Mobile Transaction component to TransactionListItem + cleanup (#3761)
* Migration mobile Transaction component to TransactionListItem + cleanup * Release notes + yarn install * Fix style * Padding changes + VRT * Update useScrollListener * Code rabbit feedback * Do not show loading on preview transactions
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
@@ -50,8 +50,8 @@
|
||||
"promise-retry": "^2.0.1",
|
||||
"re-resizable": "^6.9.17",
|
||||
"react": "18.2.0",
|
||||
"react-aria": "^3.34.3",
|
||||
"react-aria-components": "^1.3.3",
|
||||
"react-aria": "^3.35.1",
|
||||
"react-aria-components": "^1.4.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-router-dom": "6.21.3",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"react-spring": "^9.7.3",
|
||||
"react-stately": "^3.10.9",
|
||||
"react-stately": "^3.33.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"recharts": "^2.10.4",
|
||||
"redux": "^4.2.1",
|
||||
|
||||
@@ -239,8 +239,7 @@ function TransactionListWithPreviews({
|
||||
query: transactionsQuery,
|
||||
});
|
||||
|
||||
const { data: previewTransactions, isLoading: isPreviewTransactionsLoading } =
|
||||
usePreviewTransactions();
|
||||
const { data: previewTransactions } = usePreviewTransactions();
|
||||
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const dispatch = useDispatch();
|
||||
@@ -318,7 +317,7 @@ function TransactionListWithPreviews({
|
||||
|
||||
return (
|
||||
<TransactionListWithBalances
|
||||
isLoading={isLoading || isPreviewTransactionsLoading}
|
||||
isLoading={isLoading}
|
||||
transactions={transactionsToDisplay}
|
||||
balance={balanceQueries.balance}
|
||||
balanceCleared={balanceQueries.cleared}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useListBox } from 'react-aria';
|
||||
import { useListState } from 'react-stately';
|
||||
|
||||
import { useScrollListener } from '../../ScrollProvider';
|
||||
|
||||
import { ListBoxSection } from './ListBoxSection';
|
||||
|
||||
export function ListBox(props) {
|
||||
const state = useListState(props);
|
||||
const listBoxRef = useRef();
|
||||
const { listBoxProps, labelProps } = useListBox(props, state, listBoxRef);
|
||||
const { loadMore } = props;
|
||||
|
||||
useScrollListener(({ hasScrolledToEnd }) => {
|
||||
const scrolledToBottom = hasScrolledToEnd('down', 5);
|
||||
if (scrolledToBottom) {
|
||||
loadMore?.();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...labelProps}>{props.label}</div>
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
ref={listBoxRef}
|
||||
style={{
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{[...state.collection].map(item => (
|
||||
<ListBoxSection key={item.key} section={item} state={state} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useListBoxSection } from 'react-aria';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { styles, theme } from '../../../style';
|
||||
|
||||
import { Option } from './Option';
|
||||
|
||||
const zIndices = { SECTION_HEADING: 10 };
|
||||
|
||||
export function ListBoxSection({ section, state }) {
|
||||
const { itemProps, headingProps, groupProps } = useListBoxSection({
|
||||
heading: section.rendered,
|
||||
'aria-label': section['aria-label'],
|
||||
});
|
||||
|
||||
// The heading is rendered inside an <li> element, which contains
|
||||
// a <ul> with the child items.
|
||||
return (
|
||||
<li {...itemProps} style={{ width: '100%' }}>
|
||||
{section.rendered && (
|
||||
<div
|
||||
{...headingProps}
|
||||
className={css([
|
||||
styles.smallText,
|
||||
{
|
||||
backgroundColor: theme.pageBackground,
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
borderTop: `1px solid ${theme.tableBorder}`,
|
||||
color: theme.tableHeaderText,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
width: '100%',
|
||||
zIndex: zIndices.SECTION_HEADING,
|
||||
},
|
||||
])}
|
||||
>
|
||||
{section.rendered}
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
{...groupProps}
|
||||
style={{
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{[...section.childNodes].map((node, index, nodes) => (
|
||||
<Option
|
||||
key={node.key}
|
||||
item={node}
|
||||
state={state}
|
||||
isLast={index === nodes.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useFocusRing, useOption, mergeProps } from 'react-aria';
|
||||
|
||||
import { theme } from '../../../style';
|
||||
|
||||
export function Option({ isLast, item, state }) {
|
||||
// Get props for the option element
|
||||
const ref = useRef();
|
||||
const { optionProps, isSelected } = useOption({ key: item.key }, state, ref);
|
||||
|
||||
// Determine whether we should show a keyboard
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
return (
|
||||
<li
|
||||
{...mergeProps(optionProps, focusProps)}
|
||||
ref={ref}
|
||||
style={{
|
||||
background: isSelected
|
||||
? theme.tableRowBackgroundHighlight
|
||||
: theme.tableBackground,
|
||||
color: isSelected ? theme.tableText : null,
|
||||
outline: isFocusVisible ? '2px solid orange' : 'none',
|
||||
...(!isLast && { borderBottom: `1px solid ${theme.tableBorder}` }),
|
||||
}}
|
||||
>
|
||||
{item.rendered}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
import { mergeProps } from 'react-aria';
|
||||
|
||||
import {
|
||||
PressResponder,
|
||||
usePress,
|
||||
useLongPress,
|
||||
} from '@react-aria/interactions';
|
||||
|
||||
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
|
||||
import { isPreviewId } from 'loot-core/src/shared/transactions';
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
|
||||
import { useAccount } from '../../../hooks/useAccount';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { usePayee } from '../../../hooks/usePayee';
|
||||
import { SvgSplit } from '../../../icons/v0';
|
||||
import {
|
||||
SvgArrowsSynchronize,
|
||||
SvgCheckCircle1,
|
||||
SvgLockClosed,
|
||||
} from '../../../icons/v2';
|
||||
import { styles, theme } from '../../../style';
|
||||
import { makeAmountFullStyle } from '../../budget/util';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { Text } from '../../common/Text';
|
||||
import { TextOneLine } from '../../common/TextOneLine';
|
||||
import { View } from '../../common/View';
|
||||
|
||||
import { lookupName, getDescriptionPretty, Status } from './TransactionEdit';
|
||||
|
||||
const ROW_HEIGHT = 50;
|
||||
|
||||
const ListItem = ({ children, style, ...props }) => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: ROW_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
ListItem.displayName = 'ListItem';
|
||||
|
||||
export const Transaction = memo(function Transaction({
|
||||
transaction,
|
||||
isAdded,
|
||||
isSelected,
|
||||
onPress,
|
||||
onLongPress,
|
||||
style,
|
||||
}) {
|
||||
const { list: categories } = useCategories();
|
||||
|
||||
const {
|
||||
id,
|
||||
payee: payeeId,
|
||||
amount: originalAmount,
|
||||
category: categoryId,
|
||||
account: accountId,
|
||||
cleared,
|
||||
is_parent: isParent,
|
||||
is_child: isChild,
|
||||
schedule,
|
||||
} = transaction;
|
||||
|
||||
const payee = usePayee(payeeId);
|
||||
const account = useAccount(accountId);
|
||||
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);
|
||||
}
|
||||
|
||||
const categoryName = lookupName(categories, categoryId);
|
||||
|
||||
const prettyDescription = getDescriptionPretty(
|
||||
transaction,
|
||||
payee,
|
||||
transferAcct,
|
||||
);
|
||||
const specialCategory = account?.offbudget
|
||||
? 'Off Budget'
|
||||
: transferAcct && !transferAcct.offbudget
|
||||
? 'Transfer'
|
||||
: isParent
|
||||
? 'Split'
|
||||
: null;
|
||||
|
||||
const prettyCategory = specialCategory || categoryName;
|
||||
|
||||
const isReconciled = transaction.reconciled;
|
||||
const textStyle = isPreview && {
|
||||
fontStyle: 'italic',
|
||||
color: theme.pageTextLight,
|
||||
};
|
||||
|
||||
return (
|
||||
<PressResponder {...mergeProps(pressProps, longPressProps)}>
|
||||
<Button
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<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={{
|
||||
...styles.text,
|
||||
...textStyle,
|
||||
fontSize: 14,
|
||||
fontWeight: isAdded ? '600' : '400',
|
||||
...(prettyDescription === '' && {
|
||||
color: theme.tableTextLight,
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{prettyDescription || 'Empty'}
|
||||
</TextOneLine>
|
||||
</View>
|
||||
{isPreview ? (
|
||||
<Status status={categoryId} isSplit={isParent || isChild} />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -5,8 +5,8 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ListBox, Section, Header, Collection } from 'react-aria-components';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Item, Section } from 'react-stately';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
@@ -34,17 +34,16 @@ import { Menu } from '../../common/Menu';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import { useScrollListener } from '../../ScrollProvider';
|
||||
import { FloatingActionBar } from '../FloatingActionBar';
|
||||
|
||||
import { ListBox } from './ListBox';
|
||||
import { Transaction } from './Transaction';
|
||||
import { TransactionListItem } from './TransactionListItem';
|
||||
|
||||
const NOTIFICATION_BOTTOM_INSET = 75;
|
||||
|
||||
export function TransactionList({
|
||||
isLoading,
|
||||
transactions,
|
||||
isNewTransaction,
|
||||
onOpenTransaction,
|
||||
onLoadMore,
|
||||
}) {
|
||||
@@ -56,22 +55,14 @@ export function TransactionList({
|
||||
sections.length === 0 ||
|
||||
transaction.date !== sections[sections.length - 1].date
|
||||
) {
|
||||
// Mark the last transaction in the section so it can render
|
||||
// with a different border
|
||||
const lastSection = sections[sections.length - 1];
|
||||
if (lastSection && lastSection.data.length > 0) {
|
||||
const lastData = lastSection.data;
|
||||
lastData[lastData.length - 1].isLast = true;
|
||||
}
|
||||
|
||||
sections.push({
|
||||
id: `${isPreviewId(transaction.id) ? 'preview/' : ''}${transaction.date}`,
|
||||
date: transaction.date,
|
||||
data: [],
|
||||
transactions: [],
|
||||
});
|
||||
}
|
||||
|
||||
sections[sections.length - 1].data.push(transaction);
|
||||
sections[sections.length - 1].transactions.push(transaction);
|
||||
});
|
||||
return sections;
|
||||
}, [transactions]);
|
||||
@@ -79,15 +70,23 @@ export function TransactionList({
|
||||
const dispatchSelected = useSelectedDispatch();
|
||||
const selectedTransactions = useSelectedItems();
|
||||
|
||||
const onTransactionPress = (transaction, isLongPress = false) => {
|
||||
const isPreview = isPreviewId(transaction.id);
|
||||
const onTransactionPress = useCallback(
|
||||
(transaction, isLongPress = false) => {
|
||||
const isPreview = isPreviewId(transaction.id);
|
||||
if (!isPreview && (isLongPress || selectedTransactions.size > 0)) {
|
||||
dispatchSelected({ type: 'select', id: transaction.id });
|
||||
} else {
|
||||
onOpenTransaction(transaction);
|
||||
}
|
||||
},
|
||||
[dispatchSelected, onOpenTransaction, selectedTransactions],
|
||||
);
|
||||
|
||||
if (!isPreview && (isLongPress || selectedTransactions.size > 0)) {
|
||||
dispatchSelected({ type: 'select', id: transaction.id });
|
||||
} else {
|
||||
onOpenTransaction(transaction);
|
||||
useScrollListener(({ hasScrolledToEnd }) => {
|
||||
if (hasScrolledToEnd('down', 5)) {
|
||||
onLoadMore?.();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -103,65 +102,62 @@ export function TransactionList({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListBox
|
||||
aria-label="Transaction list"
|
||||
loadMore={onLoadMore}
|
||||
selectionMode="none"
|
||||
selectionMode={selectedTransactions.size > 0 ? 'multiple' : 'single'}
|
||||
selectedKeys={selectedTransactions}
|
||||
dependencies={[selectedTransactions]}
|
||||
renderEmptyState={() => (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>No transactions</Text>
|
||||
</View>
|
||||
)}
|
||||
items={sections}
|
||||
>
|
||||
{sections.length === 0 ? (
|
||||
{section => (
|
||||
<Section>
|
||||
<Item textValue="No transactions">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>No transactions</Text>
|
||||
</div>
|
||||
</Item>
|
||||
</Section>
|
||||
) : null}
|
||||
{sections.map(section => {
|
||||
return (
|
||||
<Section
|
||||
title={
|
||||
<span>{monthUtils.format(section.date, 'MMMM dd, yyyy')}</span>
|
||||
}
|
||||
key={section.id}
|
||||
<Header
|
||||
style={{
|
||||
...styles.smallText,
|
||||
backgroundColor: theme.pageBackground,
|
||||
color: theme.tableHeaderText,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
width: '100%',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{section.data.map((transaction, index, transactions) => {
|
||||
if (isPreviewId(transaction.id) && transaction.is_child) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Item
|
||||
key={transaction.id}
|
||||
style={{
|
||||
fontSize:
|
||||
index === transactions.length - 1 ? 98 : 'inherit',
|
||||
}}
|
||||
textValue={transaction.id}
|
||||
>
|
||||
<Transaction
|
||||
transaction={transaction}
|
||||
isAdded={isNewTransaction(transaction.id)}
|
||||
isSelected={selectedTransactions.has(transaction.id)}
|
||||
onPress={trans => onTransactionPress(trans)}
|
||||
onLongPress={trans => onTransactionPress(trans, true)}
|
||||
/>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
{monthUtils.format(section.date, 'MMMM dd, yyyy')}
|
||||
</Header>
|
||||
<Collection
|
||||
items={section.transactions.filter(
|
||||
t => !isPreviewId(t.id) || !t.is_child,
|
||||
)}
|
||||
addIdAndValue
|
||||
>
|
||||
{transaction => (
|
||||
<TransactionListItem
|
||||
key={transaction.id}
|
||||
value={transaction}
|
||||
onPress={trans => onTransactionPress(trans)}
|
||||
onLongPress={trans => onTransactionPress(trans, true)}
|
||||
/>
|
||||
)}
|
||||
</Collection>
|
||||
</Section>
|
||||
)}
|
||||
</ListBox>
|
||||
{selectedTransactions.size > 0 && (
|
||||
<SelectedTransactionsFloatingActionBar transactions={transactions} />
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from 'react';
|
||||
import { mergeProps } from 'react-aria';
|
||||
import { ListBoxItem } from 'react-aria-components';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
PressResponder,
|
||||
usePress,
|
||||
useLongPress,
|
||||
} from '@react-aria/interactions';
|
||||
|
||||
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
|
||||
import { isPreviewId } from 'loot-core/src/shared/transactions';
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
import { type TransactionEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useAccount } from '../../../hooks/useAccount';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { usePayee } from '../../../hooks/usePayee';
|
||||
import { SvgSplit } from '../../../icons/v0';
|
||||
import {
|
||||
SvgArrowsSynchronize,
|
||||
SvgCheckCircle1,
|
||||
SvgLockClosed,
|
||||
} from '../../../icons/v2';
|
||||
import { styles, theme } from '../../../style';
|
||||
import { makeAmountFullStyle } from '../../budget/util';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { Text } from '../../common/Text';
|
||||
import { TextOneLine } from '../../common/TextOneLine';
|
||||
import { View } from '../../common/View';
|
||||
|
||||
import { lookupName, getDescriptionPretty, Status } from './TransactionEdit';
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
|
||||
type TransactionListItemProps = ComponentPropsWithoutRef<
|
||||
typeof ListBoxItem<TransactionEntity>
|
||||
> & {
|
||||
isNewTransaction: (transaction: TransactionEntity['id']) => boolean;
|
||||
onPress: (transaction: TransactionEntity) => void;
|
||||
onLongPress: (transaction: TransactionEntity) => void;
|
||||
};
|
||||
|
||||
export function TransactionListItem({
|
||||
onPress,
|
||||
onLongPress,
|
||||
...props
|
||||
}: TransactionListItemProps) {
|
||||
const { list: categories } = useCategories();
|
||||
|
||||
const { value: transaction } = props;
|
||||
|
||||
const payee = usePayee(transaction?.payee || '');
|
||||
const account = useAccount(transaction?.account || '');
|
||||
const transferAcct = useAccount(payee?.transfer_acct || '');
|
||||
const isPreview = isPreviewId(transaction?.id || '');
|
||||
|
||||
const newTransactions = useSelector(state => state.queries.newTransactions);
|
||||
|
||||
const { longPressProps } = useLongPress({
|
||||
accessibilityDescription: 'Long press to select multiple transactions',
|
||||
onLongPress: () => {
|
||||
if (isPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLongPress(transaction!);
|
||||
},
|
||||
});
|
||||
|
||||
const { pressProps } = usePress({
|
||||
onPress: () => {
|
||||
onPress(transaction!);
|
||||
},
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
amount: originalAmount,
|
||||
category: categoryId,
|
||||
cleared: isCleared,
|
||||
reconciled: isReconciled,
|
||||
is_parent: isParent,
|
||||
is_child: isChild,
|
||||
schedule,
|
||||
} = transaction;
|
||||
|
||||
const isAdded = newTransactions.includes(id);
|
||||
|
||||
let amount = originalAmount;
|
||||
if (isPreview) {
|
||||
amount = getScheduledAmount(amount);
|
||||
}
|
||||
|
||||
const categoryName = lookupName(categories, categoryId);
|
||||
|
||||
const prettyDescription = getDescriptionPretty(
|
||||
transaction,
|
||||
payee,
|
||||
transferAcct,
|
||||
);
|
||||
const specialCategory = account?.offbudget
|
||||
? 'Off Budget'
|
||||
: transferAcct && !transferAcct.offbudget
|
||||
? 'Transfer'
|
||||
: isParent
|
||||
? 'Split'
|
||||
: null;
|
||||
|
||||
const prettyCategory = specialCategory || categoryName;
|
||||
|
||||
const textStyle: CSSProperties = {
|
||||
...styles.text,
|
||||
fontSize: 14,
|
||||
...(isPreview
|
||||
? {
|
||||
fontStyle: 'italic',
|
||||
color: theme.pageTextLight,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<ListBoxItem textValue={id} {...props}>
|
||||
{({ isSelected }) => (
|
||||
<PressResponder {...mergeProps(pressProps, longPressProps)}>
|
||||
<Button
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
height: ROW_HEIGHT,
|
||||
width: '100%',
|
||||
borderRadius: 0,
|
||||
...(isSelected
|
||||
? {
|
||||
borderWidth: '0 0 0 4px',
|
||||
borderColor: theme.mobileTransactionSelected,
|
||||
borderStyle: 'solid',
|
||||
}
|
||||
: {
|
||||
borderWidth: '0 0 1px 0',
|
||||
borderColor: theme.tableBorder,
|
||||
borderStyle: 'solid',
|
||||
}),
|
||||
...(isPreview
|
||||
? {
|
||||
backgroundColor: theme.tableRowHeaderBackground,
|
||||
}
|
||||
: {
|
||||
backgroundColor: theme.tableBackground,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{schedule && (
|
||||
<SvgArrowsSynchronize
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
marginRight: 5,
|
||||
color: textStyle.color || theme.menuItemText,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TextOneLine
|
||||
style={{
|
||||
...textStyle,
|
||||
fontWeight: isAdded ? '600' : '400',
|
||||
...(prettyDescription === '' && {
|
||||
color: theme.tableTextLight,
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{prettyDescription || 'Empty'}
|
||||
</TextOneLine>
|
||||
</View>
|
||||
{isPreview ? (
|
||||
<Status status={categoryId} isSplit={isParent || isChild} />
|
||||
) : (
|
||||
<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: isCleared
|
||||
? 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>
|
||||
<View style={{ justifyContent: 'center' }}>
|
||||
<Text
|
||||
style={{
|
||||
...textStyle,
|
||||
...makeAmountFullStyle(amount),
|
||||
}}
|
||||
>
|
||||
{integerToCurrency(amount)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Button>
|
||||
</PressResponder>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
@@ -70,12 +69,6 @@ export function TransactionListWithBalances({
|
||||
onOpenTransaction,
|
||||
onRefresh,
|
||||
}) {
|
||||
const newTransactions = useSelector(state => state.queries.newTransactions);
|
||||
|
||||
const isNewTransaction = id => {
|
||||
return newTransactions.includes(id);
|
||||
};
|
||||
|
||||
const selectedInst = useSelected('transactions', transactions);
|
||||
|
||||
return (
|
||||
@@ -111,7 +104,6 @@ export function TransactionListWithBalances({
|
||||
<TransactionList
|
||||
isLoading={isLoading}
|
||||
transactions={transactions}
|
||||
isNewTransaction={isNewTransaction}
|
||||
onLoadMore={onLoadMore}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
/>
|
||||
|
||||
@@ -397,8 +397,8 @@ export function nameForMonth(month: DateLike): string {
|
||||
return d.format(_parse(month), 'MMMM ‘yy');
|
||||
}
|
||||
|
||||
export function format(month: DateLike, str: string): string {
|
||||
return d.format(_parse(month), str);
|
||||
export function format(month: DateLike, format: string): string {
|
||||
return d.format(_parse(month), format);
|
||||
}
|
||||
|
||||
export const getDateFormatRegex = memoizeOne((format: string) => {
|
||||
|
||||
6
upcoming-release-notes/3761.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Migrate mobile Transaction component to TypeScript (TransactionListItem.tsx) + cleanup
|
||||