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
This commit is contained in:
Joel Jeremy Marquez
2024-11-18 06:38:08 -08:00
committed by GitHub
parent e170c0d274
commit 18f538c54b
27 changed files with 1948 additions and 2007 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -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",

View File

@@ -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}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View File

@@ -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} />

View File

@@ -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>
);
}

View File

@@ -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}
/>

View File

@@ -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) => {

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Migrate mobile Transaction component to TypeScript (TransactionListItem.tsx) + cleanup

3121
yarn.lock

File diff suppressed because it is too large Load Diff