diff --git a/cspell.json b/cspell.json index 183fe18ac807..4d937bad3e3c 100644 --- a/cspell.json +++ b/cspell.json @@ -265,6 +265,7 @@ "islarge", "ismedium", "isnonreimbursable", + "issmall", "ITSM", "Jakub", "janky", diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index ef4a472feeb3..487ff440279a 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -50,7 +50,12 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim }), rbr: HTMLElementModel.fromCustomModel({ tagName: 'rbr', - mixedUAStyles: {...styles.formError, ...styles.mb0}, + getMixedUAStyles: (tnode) => { + if (tnode.attributes.issmall === undefined) { + return {...styles.formError, ...styles.mb0}; + } + return {...styles.formError, ...styles.mb0, ...styles.textMicro}; + }, contentModel: HTMLContentModel.block, }), 'muted-text': HTMLElementModel.fromCustomModel({ @@ -134,22 +139,23 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim }), }), [ + styles.taskTitleMenuItem, styles.formError, styles.mb0, styles.colorMuted, + styles.mutedNormalTextLabel, styles.textLabelSupporting, styles.lh16, styles.textSupporting, styles.textLineThrough, - styles.mutedNormalTextLabel, + styles.textMicro, styles.onlyEmojisText, - styles.onlyEmojisTextLineHeight, - styles.taskTitleMenuItem, + styles.strong, styles.taskTitleMenuItemItalic, styles.em, - styles.strong, styles.h1, styles.blockquote, + styles.onlyEmojisTextLineHeight, ], ); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx index 6239e930fbe0..140949289ae1 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx @@ -1,13 +1,16 @@ import React from 'react'; +import {StyleSheet} from 'react-native'; +import type {TextStyle} from 'react-native'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {TNodeChildrenRenderer} from 'react-native-render-html'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; -function RBRRenderer({tnode}: CustomRendererProps) { +function RBRRenderer({tnode, style}: CustomRendererProps) { const styles = useThemeStyles(); const htmlAttribs = tnode.attributes; const shouldShowEllipsis = htmlAttribs?.shouldshowellipsis !== undefined; + const flattenStyle = StyleSheet.flatten(style as TextStyle); return ( ) { numberOfLines={shouldShowEllipsis ? 1 : 0} ellipsizeMode="tail" key={props.key} - style={[styles.textLabelError]} + style={[styles.textLabelError, flattenStyle]} > {props.childElement} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 5634a845716d..35d9504da9a5 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -298,6 +298,7 @@ function MoneyRequestReportTransactionList({ onCheckboxPress={toggleTransaction} columns={allReportColumns} scrollToNewTransaction={transaction.transactionID === newTransactions?.at(0)?.transactionID ? scrollToNewTransaction : undefined} + isInReportTableView /> ); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 104aabc7efab..95cc9530e0d6 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -79,11 +79,11 @@ function mapTransactionItemToSelectedEntry(item: TransactionListItemType, report ]; } -function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean) { +function mapToTransactionItemWithAdditionalInfo(item: TransactionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean) { return {...item, shouldAnimateInHighlight, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple}; } -function mapToItemWithSelectionInfo(item: SearchListItem, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean) { +function mapToItemWithAdditionalInfo(item: SearchListItem, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean) { if (isTaskListItemType(item)) { return { ...item, @@ -99,11 +99,11 @@ function mapToItemWithSelectionInfo(item: SearchListItem, selectedTransactions: } return isTransactionListItemType(item) - ? mapToTransactionItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight) + ? mapToTransactionItemWithAdditionalInfo(item, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight) : { ...item, shouldAnimateInHighlight, - transactions: item.transactions?.map((transaction) => mapToTransactionItemWithSelectionInfo(transaction, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight)), + transactions: item.transactions?.map((transaction) => mapToTransactionItemWithAdditionalInfo(transaction, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight)), isSelected: item?.transactions?.length > 0 && item.transactions?.every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected && canSelectMultiple), }; } @@ -502,7 +502,7 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS // Determine if either the base key or any transaction key matches const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; - return mapToItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight); + return mapToItemWithAdditionalInfo(item, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight); }); const hasErrors = Object.keys(searchResults?.errors ?? {}).length > 0 && !isOffline; diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx deleted file mode 100644 index 8175c05c619b..000000000000 --- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, {memo, useMemo} from 'react'; -import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; -import {getButtonRole} from '@components/Button/utils'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import {PressableWithFeedback} from '@components/Pressable'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {isCorrectSearchUserName} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; -import type {SearchPersonalDetails, SearchTransactionAction} from '@src/types/onyx/SearchResults'; -import ActionCell from './ActionCell'; -import UserInfoCellsWithArrow from './UserInfoCellsWithArrow'; - -type ExpenseItemHeaderNarrowProps = { - text?: string; - participantFrom: SearchPersonalDetails; - participantTo: SearchPersonalDetails; - participantFromDisplayName: string; - participantToDisplayName: string; - action?: SearchTransactionAction; - containerStyle?: StyleProp; - onButtonPress: () => void; - canSelectMultiple?: boolean; - isSelected?: boolean; - isDisabled?: boolean | null; - isDisabledCheckbox?: boolean; - handleCheckboxPress?: () => void; - isLoading?: boolean; -}; - -function ExpenseItemHeaderNarrow({ - participantFrom, - participantFromDisplayName, - participantTo, - participantToDisplayName, - onButtonPress, - action, - canSelectMultiple, - containerStyle, - isDisabledCheckbox, - isSelected, - isDisabled, - handleCheckboxPress, - text, - isLoading = false, -}: ExpenseItemHeaderNarrowProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const theme = useTheme(); - - // It might happen that we are missing display names for `From` or `To`, we only display arrow icon if both names exist - const shouldShowToRecipient = isCorrectSearchUserName(participantFromDisplayName) && isCorrectSearchUserName(participantToDisplayName) && !!participantTo?.accountID; - const shouldShowAction = useMemo(() => action !== CONST.SEARCH.ACTION_TYPES.VIEW && action !== CONST.SEARCH.ACTION_TYPES.REVIEW, [action]); - return ( - - - {!!canSelectMultiple && ( - handleCheckboxPress?.()} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), isDisabledCheckbox && styles.cursorDisabled, styles.mr1]} - > - - {!!isSelected && ( - - )} - - - )} - - - - - {shouldShowAction && ( - - - - )} - - ); -} - -export default memo(ExpenseItemHeaderNarrow); diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 4788ffe66b33..566fc509c41a 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -13,7 +13,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransactionYear'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -44,8 +43,7 @@ function ReportListItem({ const {isLargeScreenWidth} = useResponsiveLayout(); const dateColumnSize = useMemo(() => { - const shouldShowYearForSomeTransaction = reportItem.transactions.some((transaction) => shouldShowTransactionYear(transaction)); - return shouldShowYearForSomeTransaction ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; + return reportItem.transactions.some((transaction) => transaction.shouldShowYear) ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; }, [reportItem.transactions]); const animatedHighlightStyle = useAnimatedHighlightStyle({ @@ -102,7 +100,7 @@ function ReportListItem({ ...(sampleTransaction?.shouldShowTax ? [COLUMNS.TAX] : []), COLUMNS.TOTAL_AMOUNT, COLUMNS.ACTION, - ] as Array>; + ] satisfies Array>; return ( ({ ({ }} isParentHovered={hovered} columnWrapperStyles={[styles.ph3, styles.pv1half]} - isInReportRow + isReportItemChild /> )) diff --git a/src/components/SelectionList/Search/ReportListItemHeader.tsx b/src/components/SelectionList/Search/ReportListItemHeader.tsx index b195f1866191..1c534ce49c93 100644 --- a/src/components/SelectionList/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionList/Search/ReportListItemHeader.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {ColorValue} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -7,29 +7,24 @@ import ReportSearchHeader from '@components/ReportSearchHeader'; import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem, ReportListItemType} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; -import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {convertToDisplayString} from '@libs/CurrencyUtils'; -import {isCorrectSearchUserName} from '@libs/SearchUIUtils'; import {handleActionButtonPress} from '@userActions/Search'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; import ActionCell from './ActionCell'; -import UserInfoCellsWithArrow from './UserInfoCellsWithArrow'; +import UserInfoAndActionButtonRow from './UserInfoAndActionButtonRow'; type ReportListItemHeaderProps = { /** The report currently being looked at */ - report: OnyxEntry; + report: ReportListItemType; /** The policy tied to the expense report */ policy: OnyxEntry; - /** The section list item */ - item: TItem; - /** Callback to fire when the item is pressed */ onSelectRow: (item: TItem) => void; @@ -51,14 +46,11 @@ type ReportListItemHeaderProps = { type FirstRowReportHeaderProps = { /** The report currently being looked at */ - report: OnyxEntry; + report: ReportListItemType; /** The policy tied to the expense report */ policy: OnyxEntry; - /** The section list item */ - item: TItem; - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; @@ -102,10 +94,9 @@ function TotalCell({showTooltip, isLargeScreenWidth, reportItem}: ReportCellProp ); } -function FirstHeaderRow({ +function HeaderFirstRow({ policy, - report: moneyRequestReport, - item, + report: reportItem, onCheckboxPress, isDisabled, canSelectMultiple, @@ -115,25 +106,24 @@ function FirstHeaderRow({ }: FirstRowReportHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const reportItem = item as unknown as ReportListItemType; return ( {!!canSelectMultiple && ( onCheckboxPress?.(item)} - isChecked={item.isSelected} - containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} - disabled={!!isDisabled || item.isDisabledCheckbox} - accessibilityLabel={item.text ?? ''} + onPress={() => onCheckboxPress?.(reportItem as unknown as TItem)} + isChecked={reportItem.isSelected} + containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!reportItem.isSelected, !!reportItem.isDisabled)]} + disabled={!!isDisabled || reportItem.isDisabledCheckbox} + accessibilityLabel={reportItem.text ?? ''} shouldStopMouseDownPropagation - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), reportItem.isDisabledCheckbox && styles.cursorDisabled]} /> )} - + ({ @@ -164,8 +154,7 @@ function FirstHeaderRow({ function ReportListItemHeader({ policy, - report: moneyRequestReport, - item, + report: reportItem, onSelectRow, onCheckboxPress, isDisabled, @@ -176,79 +165,38 @@ function ReportListItemHeader({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); - const reportItem = item as unknown as ReportListItemType; const {currentSearchHash} = useSearchContext(); - const {translate} = useLocalize(); const {isLargeScreenWidth} = useResponsiveLayout(); const thereIsFromAndTo = !!reportItem?.from && !!reportItem?.to; - const showArrowComponent = (reportItem.type === CONST.REPORT.TYPE.IOU && thereIsFromAndTo) || (reportItem.type === CONST.REPORT.TYPE.EXPENSE && !!reportItem?.from); - const participantToDisplayName = useMemo( - () => reportItem?.to?.displayName ?? reportItem?.to?.login ?? translate('common.hidden'), - [reportItem?.to?.displayName, reportItem?.to?.login, translate], - ); - const shouldShowToRecipient = useMemo( - () => thereIsFromAndTo && !!reportItem?.to?.accountID && reportItem?.from?.accountID !== reportItem?.to?.accountID && !!isCorrectSearchUserName(participantToDisplayName), - [thereIsFromAndTo, reportItem?.from?.accountID, reportItem?.to?.accountID, participantToDisplayName], - ); + const showUserInfo = (reportItem.type === CONST.REPORT.TYPE.IOU && thereIsFromAndTo) || (reportItem.type === CONST.REPORT.TYPE.EXPENSE && !!reportItem?.from); + const avatarBorderColor = - StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused || !!isHovered, !!isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? + StyleUtils.getItemBackgroundColorStyle(!!reportItem.isSelected, !!isFocused || !!isHovered, !!isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? theme.highlightBG; const handleOnButtonPress = () => { - handleActionButtonPress(currentSearchHash, reportItem, () => onSelectRow(item)); + handleActionButtonPress(currentSearchHash, reportItem, () => onSelectRow(reportItem as unknown as TItem)); }; return !isLargeScreenWidth ? ( - - - - {showArrowComponent && ( - - )} - - - - - + ) : ( - ({ handleOnButtonPress={handleOnButtonPress} avatarBorderColor={avatarBorderColor} /> - + diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 2582f1fb23cc..706b37261214 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -1,14 +1,17 @@ -import React from 'react'; +import React, {useMemo} from 'react'; +import type {ValueOf} from 'type-fest'; import {useSearchContext} from '@components/Search/SearchContext'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem, TransactionListItemProps, TransactionListItemType} from '@components/SelectionList/types'; +import TransactionItemRow from '@components/TransactionItemRow'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {handleActionButtonPress} from '@libs/actions/Search'; import variables from '@styles/variables'; -import TransactionListItemRow from './TransactionListItemRow'; +import CONST from '@src/CONST'; +import UserInfoAndActionButtonRow from './UserInfoAndActionButtonRow'; function TransactionListItem({ item, @@ -32,8 +35,9 @@ function TransactionListItem({ const listItemPressableStyle = [ styles.selectionListPressableItemWrapper, - styles.pv3, - styles.ph3, + styles.pv0, + !isLargeScreenWidth && styles.pt3, + styles.ph0, // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle styles.bgTransparent, item.isSelected && styles.activeComponentBG, @@ -53,6 +57,28 @@ function TransactionListItem({ backgroundColor: theme.highlightBG, }); + const dateColumnSize = useMemo(() => { + return transactionItem.shouldShowYear ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; + }, [transactionItem]); + + const columns = useMemo( + () => + [ + CONST.REPORT.TRANSACTION_LIST.COLUMNS.RECEIPT, + CONST.REPORT.TRANSACTION_LIST.COLUMNS.TYPE, + CONST.REPORT.TRANSACTION_LIST.COLUMNS.DATE, + CONST.REPORT.TRANSACTION_LIST.COLUMNS.MERCHANT, + CONST.REPORT.TRANSACTION_LIST.COLUMNS.FROM, + CONST.REPORT.TRANSACTION_LIST.COLUMNS.TO, + ...(transactionItem?.shouldShowCategory ? [CONST.REPORT.TRANSACTION_LIST.COLUMNS.CATEGORY] : []), + ...(transactionItem?.shouldShowTag ? [CONST.REPORT.TRANSACTION_LIST.COLUMNS.TAG] : []), + ...(transactionItem?.shouldShowTax ? [CONST.REPORT.TRANSACTION_LIST.COLUMNS.TAX] : []), + CONST.REPORT.TRANSACTION_LIST.COLUMNS.TOTAL_AMOUNT, + CONST.REPORT.TRANSACTION_LIST.COLUMNS.ACTION, + ] satisfies Array>, + [transactionItem?.shouldShowCategory, transactionItem?.shouldShowTag, transactionItem?.shouldShowTax], + ); + return ( ({ hoverStyle={item.isSelected && styles.activeComponentBG} pressableWrapperStyle={[styles.mh5, animatedHighlightStyle]} > - { - handleActionButtonPress(currentSearchHash, transactionItem, () => onSelectRow(item)); - }} - onCheckboxPress={() => onCheckboxPress?.(item)} - isDisabled={!!isDisabled} - canSelectMultiple={!!canSelectMultiple} - isButtonSelected={item.isSelected} - shouldShowTransactionCheckbox={false} - isLoading={isLoading ?? transactionItem.isActionLoading} - /> + {(hovered) => ( + <> + {!isLargeScreenWidth && ( + { + handleActionButtonPress(currentSearchHash, transactionItem, () => onSelectRow(item)); + }} + shouldShowUserInfo={!!transactionItem?.from} + /> + )} + { + handleActionButtonPress(currentSearchHash, transactionItem, () => onSelectRow(item)); + }} + onCheckboxPress={() => onCheckboxPress?.(item)} + shouldUseNarrowLayout={!isLargeScreenWidth} + columns={columns} + isParentHovered={hovered} + isActionLoading={isLoading ?? transactionItem.isActionLoading} + isSelected={!!transactionItem.isSelected} + dateColumnSize={dateColumnSize} + shouldShowCheckbox={!!canSelectMultiple} + /> + + )} ); } diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx deleted file mode 100644 index c20f660d56ab..000000000000 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ /dev/null @@ -1,474 +0,0 @@ -import {Str} from 'expensify-common'; -import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; -import {getButtonRole} from '@components/Button/utils'; -import Checkbox from '@components/Checkbox'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import {PressableWithFeedback} from '@components/Pressable'; -import ReceiptImage from '@components/ReceiptImage'; -import type {TransactionListItemType} from '@components/SelectionList/types'; -import TextWithTooltip from '@components/TextWithTooltip'; -import Tooltip from '@components/Tooltip'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; -import {getFileName} from '@libs/fileDownload/FileUtils'; -import Parser from '@libs/Parser'; -import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; -import StringUtils from '@libs/StringUtils'; -import { - getCreated, - getTagForDisplay, - getTaxAmount, - getCurrency as getTransactionCurrency, - getDescription as getTransactionDescription, - hasReceiptSource, - isExpensifyCardTransaction, - isPending, - isScanning, -} from '@libs/TransactionUtils'; -import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import type {SearchTransactionType} from '@src/types/onyx/SearchResults'; -import ActionCell from './ActionCell'; -import DateCell from './DateCell'; -import ExpenseItemHeaderNarrow from './ExpenseItemHeaderNarrow'; -import TextWithIconCell from './TextWithIconCell'; -import UserInfoCell from './UserInfoCell'; - -type CellProps = { - // eslint-disable-next-line react/no-unused-prop-types - showTooltip: boolean; - // eslint-disable-next-line react/no-unused-prop-types - isLargeScreenWidth: boolean; -}; - -type TransactionCellProps = { - transactionItem: TransactionListItemType; -} & CellProps; - -type TotalCellProps = { - // eslint-disable-next-line react/no-unused-prop-types - isChildListItem: boolean; -} & TransactionCellProps; - -type TransactionListItemRowProps = { - item: TransactionListItemType; - showTooltip: boolean; - onButtonPress: () => void; - onCheckboxPress: () => void; - showItemHeaderOnNarrowLayout?: boolean; - containerStyle?: StyleProp; - isChildListItem?: boolean; - isDisabled: boolean; - canSelectMultiple: boolean; - isButtonSelected?: boolean; - parentAction?: string; - shouldShowTransactionCheckbox?: boolean; - isLoading?: boolean; -}; - -const getTypeIcon = (type?: SearchTransactionType) => { - switch (type) { - case CONST.SEARCH.TRANSACTION_TYPE.CASH: - return Expensicons.Cash; - case CONST.SEARCH.TRANSACTION_TYPE.CARD: - return Expensicons.CreditCard; - case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: - return Expensicons.Car; - default: - return Expensicons.Cash; - } -}; - -function ReceiptCell({transactionItem}: TransactionCellProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - - const backgroundStyles = transactionItem.isSelected ? StyleUtils.getBackgroundColorStyle(theme.buttonHoveredBG) : StyleUtils.getBackgroundColorStyle(theme.border); - - let source = transactionItem?.receipt?.source ?? ''; - if (source && typeof source === 'string') { - const filename = getFileName(source); - const receiptURIs = getThumbnailAndImageURIs(transactionItem, null, filename); - const isReceiptPDF = Str.isPDF(filename); - source = tryResolveUrlFromApiRoot(isReceiptPDF && !receiptURIs.isLocalFile ? (receiptURIs.thumbnail ?? '') : (receiptURIs.image ?? '')); - } - - return ( - - - - ); -} - -function MerchantCell({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const description = getTransactionDescription(transactionItem); - let merchantOrDescriptionToDisplay = transactionItem.formattedMerchant; - if (!merchantOrDescriptionToDisplay && !isLargeScreenWidth) { - merchantOrDescriptionToDisplay = Parser.htmlToText(description); - } - let merchant = transactionItem.shouldShowMerchant ? merchantOrDescriptionToDisplay : Parser.htmlToText(description); - - if (isScanning(transactionItem) && transactionItem.shouldShowMerchant) { - merchant = translate('iou.receiptStatusTitle'); - } - const merchantToDisplay = StringUtils.getFirstLine(merchant); - return ( - - ); -} - -function TotalCell({showTooltip, isLargeScreenWidth, transactionItem}: TotalCellProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const currency = getTransactionCurrency(transactionItem); - let amount = convertToDisplayString(transactionItem.formattedTotal, currency); - - if (isScanning(transactionItem)) { - amount = translate('iou.receiptStatusTitle'); - } - - return ( - - ); -} - -function TypeCell({transactionItem, isLargeScreenWidth}: TransactionCellProps) { - const theme = useTheme(); - const {translate} = useLocalize(); - const isPendingExpensifyCardTransaction = isExpensifyCardTransaction(transactionItem) && isPending(transactionItem); - const typeIcon = isPendingExpensifyCardTransaction ? Expensicons.CreditCardHourglass : getTypeIcon(transactionItem.transactionType); - - const tooltipText = isPendingExpensifyCardTransaction ? translate('iou.pending') : ''; - - return ( - - - - - - ); -} - -function CategoryCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) { - const styles = useThemeStyles(); - return ( - - ); -} - -function TagCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) { - const styles = useThemeStyles(); - return isLargeScreenWidth ? ( - - ) : ( - - ); -} - -function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { - const styles = useThemeStyles(); - - const isFromExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE; - const taxAmount = getTaxAmount(transactionItem, isFromExpenseReport); - const currency = getTransactionCurrency(transactionItem); - - return ( - - ); -} - -function TransactionListItemRow({ - item, - showTooltip, - isDisabled, - canSelectMultiple, - onButtonPress, - onCheckboxPress, - showItemHeaderOnNarrowLayout = true, - containerStyle, - isChildListItem = false, - isButtonSelected = false, - parentAction = '', - shouldShowTransactionCheckbox, - isLoading = false, -}: TransactionListItemRowProps) { - const styles = useThemeStyles(); - const {isLargeScreenWidth} = useResponsiveLayout(); - const StyleUtils = useStyleUtils(); - const theme = useTheme(); - - const created = getCreated(item); - - if (!isLargeScreenWidth) { - return ( - - {showItemHeaderOnNarrowLayout && ( - - )} - - - {canSelectMultiple && !!shouldShowTransactionCheckbox && ( - - - {!!item.isSelected && ( - - )} - - - )} - - - - {!!item.category && ( - - - - )} - - - - - - - - - - - ); - } - - return ( - - {canSelectMultiple && ( - - )} - - - - - - - - - - - - - - - - - - - - {item.shouldShowCategory && ( - - - - )} - {item.shouldShowTag && ( - - - - )} - {item.shouldShowTax && ( - - - - )} - - - - - - - - - - ); -} - -TransactionListItemRow.displayName = 'TransactionListItemRow'; - -export default TransactionListItemRow; diff --git a/src/components/SelectionList/Search/UserInfoAndActionButtonRow.tsx b/src/components/SelectionList/Search/UserInfoAndActionButtonRow.tsx new file mode 100644 index 000000000000..a440b3491771 --- /dev/null +++ b/src/components/SelectionList/Search/UserInfoAndActionButtonRow.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {isCorrectSearchUserName} from '@libs/SearchUIUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ActionCell from './ActionCell'; +import UserInfoCellsWithArrow from './UserInfoCellsWithArrow'; + +function UserInfoAndActionButtonRow({ + item, + handleActionButtonPress, + shouldShowUserInfo, +}: { + item: ReportListItemType | TransactionListItemType; + handleActionButtonPress: () => void; + shouldShowUserInfo: boolean; +}) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const hasFromSender = !!item?.from && !!item?.from?.accountID && !!item?.from?.displayName; + const hasToRecipient = !!item?.to && !!item?.to?.accountID && !!item?.to?.displayName; + const participantFromDisplayName = item?.from?.displayName ?? item?.from?.login ?? translate('common.hidden'); + const participantToDisplayName = item?.to?.displayName ?? item?.to?.login ?? translate('common.hidden'); + const shouldShowToRecipient = + hasFromSender && hasToRecipient && !!item?.to?.accountID && item?.from?.accountID !== item?.to?.accountID && !!isCorrectSearchUserName(participantToDisplayName); + + return ( + + + {shouldShowUserInfo && ( + + )} + + + + + + ); +} + +export default UserInfoAndActionButtonRow; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 96244d6a2801..5ce7a53984cb 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -22,7 +22,7 @@ import type SpendCategorySelectorListItem from '@pages/workspace/categories/Spen // eslint-disable-next-line no-restricted-imports import type CursorStyles from '@styles/utils/cursor/types'; import type CONST from '@src/CONST'; -import type {Policy, Report} from '@src/types/onyx'; +import type {Policy, Report, TransactionViolation} from '@src/types/onyx'; import type {Attendee, SplitExpense} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {SearchPersonalDetails, SearchReport, SearchReportAction, SearchTask, SearchTransaction} from '@src/types/onyx/SearchResults'; @@ -259,6 +259,9 @@ type TransactionListItemType = ListItem & /** Attendees in the transaction */ attendees?: Attendee[]; + + /** Precomputed violations */ + violations?: TransactionViolation[]; }; type ReportActionListItemType = ListItem & diff --git a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx index 092c8a84e83d..a308355e94f5 100644 --- a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx +++ b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx @@ -1,24 +1,26 @@ import React from 'react'; import TextWithTooltip from '@components/TextWithTooltip'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getMerchant} from '@libs/TransactionUtils'; -import CONST from '@src/CONST'; -import type TransactionDataCellProps from './TransactionDataCellProps'; -function MerchantCell({transactionItem, shouldShowTooltip, shouldUseNarrowLayout}: TransactionDataCellProps) { +function MerchantOrDescriptionCell({ + merchantOrDescription, + shouldShowTooltip, + shouldUseNarrowLayout, +}: { + merchantOrDescription: string; + shouldUseNarrowLayout?: boolean | undefined; + shouldShowTooltip: boolean; +}) { const styles = useThemeStyles(); - const merchantName = getMerchant(transactionItem); - const merchantToDisplay = merchantName === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ? '' : merchantName; - return ( ); } -MerchantCell.displayName = 'MerchantCell'; -export default MerchantCell; +MerchantOrDescriptionCell.displayName = 'MerchantOrDescriptionCell'; +export default MerchantOrDescriptionCell; diff --git a/src/components/TransactionItemRow/DataCells/TypeCell.tsx b/src/components/TransactionItemRow/DataCells/TypeCell.tsx index 2a9996b7d7a5..f22d335ec155 100644 --- a/src/components/TransactionItemRow/DataCells/TypeCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TypeCell.tsx @@ -5,6 +5,7 @@ import TextWithTooltip from '@components/TextWithTooltip'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {isExpensifyCardTransaction, isPending} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type TransactionDataCellProps from './TransactionDataCellProps'; @@ -48,8 +49,9 @@ function TypeCell({transactionItem, shouldUseNarrowLayout, shouldShowTooltip}: T const {translate} = useLocalize(); const theme = useTheme(); const type = transactionItem.transactionType ?? getType(transactionItem.cardName); - const typeIcon = getTypeIcon(type); - const typeText = getTypeText(type); + const isPendingExpensifyCardTransaction = isExpensifyCardTransaction(transactionItem) && isPending(transactionItem); + const typeIcon = isPendingExpensifyCardTransaction ? Expensicons.CreditCardHourglass : getTypeIcon(type); + const typeText = isPendingExpensifyCardTransaction ? 'iou.pending' : getTypeText(type); const styles = useThemeStyles(); return shouldUseNarrowLayout ? ( diff --git a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx index 5828fd1ab1cc..4849ee04d864 100644 --- a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx @@ -1,88 +1,35 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import type {ViewStyle} from 'react-native'; import {View} from 'react-native'; import Icon from '@components/Icon'; import {DotIndicator} from '@components/Icon/Expensicons'; -import type {LocaleContextProps} from '@components/LocaleContextProvider'; import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useTransactionViolations from '@hooks/useTransactionViolations'; -import {isReceiptError} from '@libs/ErrorUtils'; -import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import variables from '@styles/variables'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type ReportAction from '@src/types/onyx/ReportAction'; -import type Transaction from '@src/types/onyx/Transaction'; -import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; +import type {TransactionViolations} from '@src/types/onyx'; type TransactionItemRowRBRProps = { /** Transaction item */ - transaction: Transaction; + transactionViolations?: TransactionViolations; /** Styles for the RBR messages container */ containerStyles?: ViewStyle[]; }; -/** - * Extracts unique error messages from errors and actions - */ -const extractErrorMessages = (errors: Errors | ReceiptErrors, errorActions: ReportAction[], translate: LocaleContextProps['translate']): string[] => { - const uniqueMessages = new Set(); - - // Combine transaction and action errors - let allErrors: Record = {...errors}; - errorActions.forEach((action) => { - if (!action.errors) { - return; - } - allErrors = {...allErrors, ...action.errors}; - }); - - // Extract error messages - Object.values(allErrors).forEach((errorValue) => { - if (!errorValue) { - return; - } - if (typeof errorValue === 'string') { - uniqueMessages.add(errorValue); - } else if (isReceiptError(errorValue)) { - uniqueMessages.add(translate('iou.error.receiptFailureMessageShort')); - } else { - Object.values(errorValue).forEach((nestedErrorValue) => { - if (!nestedErrorValue) { - return; - } - uniqueMessages.add(nestedErrorValue); - }); - } - }); - - return Array.from(uniqueMessages); -}; - -function TransactionItemRowRBR({transaction, containerStyles}: TransactionItemRowRBRProps) { +/** This component is lighter version of TransactionItemRowRBRWithOnyx that doesn't use onyx but uses transactionViolations data computed from search, + * thus it doesn't include violations taken from reportActions like its counterpart does. */ +function TransactionItemRowRBR({transactionViolations, containerStyles}: TransactionItemRowRBRProps) { const styles = useThemeStyles(); - const transactionViolations = useTransactionViolations(transaction?.transactionID); const {translate} = useLocalize(); const theme = useTheme(); - - const {sortedAllReportActions: transactionActions} = usePaginatedReportActions(transaction.reportID); - const transactionThreadId = transactionActions ? getIOUActionForTransactionID(transactionActions, transaction.transactionID)?.childReportID : undefined; - const {sortedAllReportActions: transactionThreadActions} = usePaginatedReportActions(transactionThreadId); - const getErrorMessages = useCallback( - (errors: Errors | ReceiptErrors | undefined = {}, errorActions: ReportAction[] | undefined = []) => extractErrorMessages(errors, errorActions, translate), - [translate], - ); + if (!transactionViolations) { + return null; + } const RBRMessages = [ - ...getErrorMessages( - transaction?.errors, - transactionThreadActions?.filter((e) => !!e.errors), - ), // Some violations end with a period already so lets make sure the connected messages have only single period between them // and end with a single dot. ...transactionViolations.map((violation) => { @@ -92,19 +39,20 @@ function TransactionItemRowRBR({transaction, containerStyles}: TransactionItemRo ].join(' '); return ( RBRMessages.length > 0 && ( - + - - ${RBRMessages}`} /> + + ${RBRMessages}`} /> ) ); } +TransactionItemRowRBR.displayName = 'TransactionItemRowRBR'; export default TransactionItemRowRBR; diff --git a/src/components/TransactionItemRow/TransactionItemRowRBRWithOnyx.tsx b/src/components/TransactionItemRow/TransactionItemRowRBRWithOnyx.tsx new file mode 100644 index 000000000000..a1273181ad31 --- /dev/null +++ b/src/components/TransactionItemRow/TransactionItemRowRBRWithOnyx.tsx @@ -0,0 +1,111 @@ +import React, {useCallback} from 'react'; +import type {ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import {DotIndicator} from '@components/Icon/Expensicons'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import RenderHTML from '@components/RenderHTML'; +import useLocalize from '@hooks/useLocalize'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionViolations from '@hooks/useTransactionViolations'; +import {isReceiptError} from '@libs/ErrorUtils'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import variables from '@styles/variables'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type Transaction from '@src/types/onyx/Transaction'; +import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; + +type TransactionItemRowRBRProps = { + /** Transaction item */ + transaction: Transaction; + + /** Styles for the RBR messages container */ + containerStyles?: ViewStyle[]; +}; + +/** + * Extracts unique error messages from errors and actions + */ +const extractErrorMessages = (errors: Errors | ReceiptErrors, errorActions: ReportAction[], translate: LocaleContextProps['translate']): string[] => { + const uniqueMessages = new Set(); + + // Combine transaction and action errors + let allErrors: Record = {...errors}; + errorActions.forEach((action) => { + if (!action.errors) { + return; + } + allErrors = {...allErrors, ...action.errors}; + }); + + // Extract error messages + Object.values(allErrors).forEach((errorValue) => { + if (!errorValue) { + return; + } + if (typeof errorValue === 'string') { + uniqueMessages.add(errorValue); + } else if (isReceiptError(errorValue)) { + uniqueMessages.add(translate('iou.error.receiptFailureMessageShort')); + } else { + Object.values(errorValue).forEach((nestedErrorValue) => { + if (!nestedErrorValue) { + return; + } + uniqueMessages.add(nestedErrorValue); + }); + } + }); + + return Array.from(uniqueMessages); +}; + +function TransactionItemRowRBRWithOnyx({transaction, containerStyles}: TransactionItemRowRBRProps) { + const styles = useThemeStyles(); + const transactionViolations = useTransactionViolations(transaction?.transactionID); + const {translate} = useLocalize(); + const theme = useTheme(); + + const {sortedAllReportActions: transactionActions} = usePaginatedReportActions(transaction.reportID); + const transactionThreadId = transactionActions ? getIOUActionForTransactionID(transactionActions, transaction.transactionID)?.childReportID : undefined; + const {sortedAllReportActions: transactionThreadActions} = usePaginatedReportActions(transactionThreadId); + const getErrorMessages = useCallback( + (errors: Errors | ReceiptErrors | undefined = {}, errorActions: ReportAction[] | undefined = []) => extractErrorMessages(errors, errorActions, translate), + [translate], + ); + + const RBRMessages = [ + ...getErrorMessages( + transaction?.errors, + transactionThreadActions?.filter((e) => !!e.errors), + ), + // Some violations end with a period already so lets make sure the connected messages have only single period between them + // and end with a single dot. + ...transactionViolations.map((violation) => { + const message = ViolationsUtils.getViolationTranslation(violation, translate); + return message.endsWith('.') || transactionViolations.length === 1 ? message : `${message}.`; + }), + ].join(' '); + return ( + RBRMessages.length > 0 && ( + + + + ${RBRMessages}`} /> + + + ) + ); +} + +TransactionItemRowRBRWithOnyx.displayName = 'TransactionItemRowRBRWithOnyx'; +export default TransactionItemRowRBRWithOnyx; diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index b3a129f1504c..a7eca1f30eca 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -13,22 +13,36 @@ import UserInfoCell from '@components/SelectionList/Search/UserInfoCell'; import Text from '@components/Text'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useHover from '@hooks/useHover'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getMerchant, getCreated as getTransactionCreated, getTransactionPendingAction, isPartialMerchant, isTransactionPendingDelete} from '@libs/TransactionUtils'; +import Parser from '@libs/Parser'; +import StringUtils from '@libs/StringUtils'; +import { + getDescription, + getMerchant, + getCreated as getTransactionCreated, + getTransactionPendingAction, + hasReceipt, + isReceiptBeingScanned, + isTransactionPendingDelete, +} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type {TransactionViolation} from '@src/types/onyx'; import type {SearchPersonalDetails, SearchTransactionAction} from '@src/types/onyx/SearchResults'; import CategoryCell from './DataCells/CategoryCell'; import ChatBubbleCell from './DataCells/ChatBubbleCell'; -import MerchantCell from './DataCells/MerchantCell'; +import MerchantOrDescriptionCell from './DataCells/MerchantCell'; import ReceiptCell from './DataCells/ReceiptCell'; import TagCell from './DataCells/TagCell'; import TaxCell from './DataCells/TaxCell'; import TotalCell from './DataCells/TotalCell'; import TypeCell from './DataCells/TypeCell'; import TransactionItemRowRBR from './TransactionItemRowRBR'; +import TransactionItemRowRBRWithOnyx from './TransactionItemRowRBRWithOnyx'; type ColumnComponents = { [key in ValueOf]: React.ReactElement; @@ -47,10 +61,61 @@ type TransactionWithOptionalSearchFields = TransactionWithOptionalHighlight & { /** The personal details of the user paying the request */ to?: SearchPersonalDetails; + /** formatted "to" value used for displaying and sorting on Reports page */ + formattedTo?: string; + + /** formatted "from" value used for displaying and sorting on Reports page */ + formattedFrom?: string; + + /** formatted "merchant" value used for displaying and sorting on Reports page */ + formattedMerchant?: string; + + /** information about whether to show merchant, that is provided on Reports page */ + shouldShowMerchant?: boolean; + /** Type of transaction */ transactionType?: ValueOf; + + /** Precomputed violations */ + violations?: TransactionViolation[]; +}; + +type TransactionItemRowProps = { + transactionItem: TransactionWithOptionalSearchFields; + shouldUseNarrowLayout: boolean; + isSelected: boolean; + shouldShowTooltip: boolean; + dateColumnSize: TableColumnSize; + onCheckboxPress: (transactionID: string) => void; + shouldShowCheckbox: boolean; + columns?: Array>; + onButtonPress?: () => void; + isParentHovered?: boolean; + columnWrapperStyles?: ViewStyle[]; + scrollToNewTransaction?: ((offset: number) => void) | undefined; + isReportItemChild?: boolean; + isActionLoading?: boolean; + isInReportTableView?: boolean; }; +/** If merchant name is empty or (none), then it falls back to description if screen is narrow */ +function getMerchantNameWithFallback(transactionItem: TransactionWithOptionalSearchFields, translate: (key: TranslationPaths) => string, shouldUseNarrowLayout?: boolean | undefined) { + const shouldShowMerchant = transactionItem.shouldShowMerchant ?? true; + const description = getDescription(transactionItem); + let merchantOrDescriptionToDisplay = transactionItem?.formattedMerchant ?? getMerchant(transactionItem); + const merchantNameEmpty = !merchantOrDescriptionToDisplay || merchantOrDescriptionToDisplay === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + if (merchantNameEmpty && shouldUseNarrowLayout) { + merchantOrDescriptionToDisplay = Parser.htmlToText(description); + } + let merchant = shouldShowMerchant ? merchantOrDescriptionToDisplay : Parser.htmlToText(description); + + if (hasReceipt(transactionItem) && isReceiptBeingScanned(transactionItem) && shouldShowMerchant) { + merchant = translate('iou.receiptStatusTitle'); + } + const merchantName = StringUtils.getFirstLine(merchant); + return merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ? merchantName : ''; +} + function TransactionItemRow({ transactionItem, shouldUseNarrowLayout, @@ -64,23 +129,12 @@ function TransactionItemRow({ isParentHovered, columnWrapperStyles, scrollToNewTransaction, - isInReportRow = false, -}: { - transactionItem: TransactionWithOptionalSearchFields; - shouldUseNarrowLayout: boolean; - isSelected: boolean; - shouldShowTooltip: boolean; - dateColumnSize: TableColumnSize; - onCheckboxPress: (transactionID: string) => void; - shouldShowCheckbox: boolean; - columns?: Array>; - onButtonPress?: () => void; - isParentHovered?: boolean; - columnWrapperStyles?: ViewStyle[]; - scrollToNewTransaction?: ((offset: number) => void) | undefined; - isInReportRow?: boolean; -}) { + isReportItemChild = false, + isActionLoading, + isInReportTableView = false, +}: TransactionItemRowProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const StyleUtils = useStyleUtils(); const theme = useTheme(); const pendingAction = getTransactionPendingAction(transactionItem); @@ -110,8 +164,7 @@ function TransactionItemRow({ } }, [hovered, isParentHovered, isSelected, styles.activeComponentBG, styles.hoveredComponentBG]); - const merchantName = getMerchant(transactionItem); - const isMerchantEmpty = isPartialMerchant(merchantName); + const merchantOrDescriptionName = useMemo(() => getMerchantNameWithFallback(transactionItem, translate, shouldUseNarrowLayout), [shouldUseNarrowLayout, transactionItem, translate]); useEffect(() => { if (!transactionItem.shouldBeHighlighted || !scrollToNewTransaction) { @@ -174,22 +227,24 @@ function TransactionItemRow({ {!!transactionItem.action && ( )} ), [CONST.REPORT.TRANSACTION_LIST.COLUMNS.MERCHANT]: ( - + {!!merchantOrDescriptionName && ( + + )} ), [CONST.REPORT.TRANSACTION_LIST.COLUMNS.TO]: ( @@ -198,7 +253,7 @@ function TransactionItemRow({ )} @@ -209,7 +264,7 @@ function TransactionItemRow({ )} @@ -237,7 +292,19 @@ function TransactionItemRow({ ), }), - [StyleUtils, createdAt, isDateColumnWide, isSelected, onButtonPress, shouldShowTooltip, shouldUseNarrowLayout, transactionItem], + [ + StyleUtils, + createdAt, + isActionLoading, + isReportItemChild, + isDateColumnWide, + isSelected, + merchantOrDescriptionName, + onButtonPress, + shouldShowTooltip, + shouldUseNarrowLayout, + transactionItem, + ], ); const safeColumnWrapperStyle = columnWrapperStyles ?? [styles.p3, styles.expenseWidgetRadius]; return ( @@ -249,8 +316,8 @@ function TransactionItemRow({ > {shouldUseNarrowLayout ? ( - - + + {shouldShowCheckbox && ( @@ -283,7 +350,7 @@ function TransactionItemRow({ shouldShowTooltip={shouldShowTooltip} shouldUseNarrowLayout={shouldUseNarrowLayout} /> - {isMerchantEmpty && ( + {!merchantOrDescriptionName && ( )} - {!isMerchantEmpty && ( + {!!merchantOrDescriptionName && ( - @@ -309,10 +376,10 @@ function TransactionItemRow({ )} - - + + {hasCategoryOrTag && ( - + )} - ) : ( - + @@ -353,7 +420,12 @@ function TransactionItemRow({ {columns?.map((column) => columnComponent[column])} - + {} + {isInReportTableView ? ( + + ) : ( + // We are rendering this component only if we are not in the report table view for performance reasons + )} )} diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 62902df71b34..f8fbd8c60787 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -62,7 +62,13 @@ import { import {buildCannedSearchQuery, buildQueryStringFromFilterFormValues} from './SearchQueryUtils'; import StringUtils from './StringUtils'; import {shouldRestrictUserBillableActions} from './SubscriptionUtils'; -import {getAmount as getTransactionAmount, getCreated as getTransactionCreatedDate, getMerchant as getTransactionMerchant, isPendingCardOrScanningTransaction} from './TransactionUtils'; +import { + getAmount as getTransactionAmount, + getCreated as getTransactionCreatedDate, + getMerchant as getTransactionMerchant, + isPendingCardOrScanningTransaction, + isViolationDismissed, +} from './TransactionUtils'; import shouldShowTransactionYear from './TransactionUtils/shouldShowTransactionYear'; const transactionColumnNamesToSortingProperty = { @@ -316,6 +322,14 @@ function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | return false; } +/** + * @private + * Extracts all transaction violations from the search data. + */ +function getViolations(data: OnyxTypes.SearchResults['data']): OnyxCollection { + return Object.fromEntries(Object.entries(data).filter(([key]) => isViolationEntry(key))) as OnyxCollection; +} + /** * @private * Generates a display name for IOU reports considering the personal details of the payer and the transaction details. @@ -339,6 +353,14 @@ function getIOUReportName(data: OnyxTypes.SearchResults['data'], reportItem: Sea }); } +function getTransactionViolations(allViolations: OnyxCollection, transaction: SearchTransaction): OnyxTypes.TransactionViolation[] { + const transactionViolations = allViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; + if (!transactionViolations) { + return []; + } + return transactionViolations.filter((violation) => !isViolationDismissed(transaction, violation)); +} + /** * @private * Organizes data into List Sections for display, for the TransactionListItemType of Search Results. @@ -354,6 +376,8 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata // Pre-filter transaction keys to avoid repeated checks const transactionKeys = Object.keys(data).filter(isTransactionEntry); + // Get violations - optimize by using a Map for faster lookups + const allViolations = getViolations(data); // Use Map for faster lookups of personal details const personalDetailsMap = new Map(Object.entries(data.personalDetailsList || {})); @@ -366,6 +390,7 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const shouldShowBlankTo = !report || isOpenExpenseReport(report); + const transactionViolations = getTransactionViolations(allViolations, transactionItem); // Use Map.get() for faster lookups with default values const from = personalDetailsMap.get(transactionItem.accountID.toString()) ?? emptyPersonalDetails; const to = transactionItem.managerID && !shouldShowBlankTo ? (personalDetailsMap.get(transactionItem.managerID.toString()) ?? emptyPersonalDetails) : emptyPersonalDetails; @@ -373,7 +398,7 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date} = getTransactionItemCommonFormattedProperties(transactionItem, from, to, policy); const transactionSection: TransactionListItemType = { - action: getAction(data, key), + action: getAction(data, allViolations, key), from, to, formattedFrom, @@ -387,6 +412,7 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata shouldShowTax, keyForList: transactionItem.transactionID, shouldShowYear: doesDataContainAPastYearTransaction, + violations: transactionViolations, // Manually copying all the properties from transactionItem transactionID: transactionItem.transactionID, @@ -442,14 +468,6 @@ function getTransactionsForReport(data: OnyxTypes.SearchResults['data'], reportI .map(([, value]) => value as SearchTransaction); } -/** - * @private - * Extracts all transaction violations from the search data. - */ -function getViolations(data: OnyxTypes.SearchResults['data']): OnyxCollection { - return Object.fromEntries(Object.entries(data).filter(([key]) => isViolationEntry(key))) as OnyxCollection; -} - /** * @private * Retrieves a report from the search data based on the provided key. @@ -513,7 +531,7 @@ function getReviewerPermissionFlags( * * Do not use directly, use only via `getSections()` facade. */ -function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTransactionAction { +function getAction(data: OnyxTypes.SearchResults['data'], allViolations: OnyxCollection, key: string): SearchTransactionAction { const isTransaction = isTransactionEntry(key); const report = getReportFromKey(data, key); @@ -550,8 +568,7 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr } else { allReportTransactions = transaction ? [transaction] : []; } - // Get violations - optimize by using a Map for faster lookups - const allViolations = getViolations(data); + const policy = getPolicyFromKey(data, report) as OnyxTypes.Policy; const {isSubmitter, isAdmin, isApprover} = getReviewerPermissionFlags(report, policy); @@ -729,6 +746,8 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx const shouldShowMerchant = getShouldShowMerchant(data); const doesDataContainAPastYearTransaction = shouldShowYear(data); + // Get violations - optimize by using a Map for faster lookups + const allViolations = getViolations(data); const reportIDToTransactions: Record = {}; for (const key in data) { @@ -740,7 +759,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...reportItem, - action: getAction(data, key), + action: getAction(data, allViolations, key), keyForList: reportItem.reportID, from: data.personalDetailsList?.[reportItem.accountID ?? CONST.DEFAULT_NUMBER_ID], to: reportItem.managerID ? data.personalDetailsList?.[reportItem.managerID] : emptyPersonalDetails, @@ -756,6 +775,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx const report = data[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`]; const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const shouldShowBlankTo = !report || isOpenExpenseReport(report); + const transactionViolations = getTransactionViolations(allViolations, transactionItem); const from = data.personalDetailsList?.[transactionItem.accountID]; const to = transactionItem.managerID && !shouldShowBlankTo ? (data.personalDetailsList?.[transactionItem.managerID] ?? emptyPersonalDetails) : emptyPersonalDetails; @@ -764,7 +784,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx const transaction = { ...transactionItem, - action: getAction(data, key), + action: getAction(data, allViolations, key), from, to, formattedFrom, @@ -778,6 +798,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx shouldShowTax: metadata?.columnsToShow?.shouldShowTaxColumn, keyForList: transactionItem.transactionID, shouldShowYear: doesDataContainAPastYearTransaction, + violations: transactionViolations, }; if (reportIDToTransactions[reportKey]?.transactions) { reportIDToTransactions[reportKey].transactions.push(transaction); diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 47c4f733a472..c1bfcbac5095 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -759,6 +759,10 @@ export default { columnGap: 12, }, + minHeight4: { + minHeight: 16, + }, + minHeight5: { minHeight: 20, }, diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index a2998d60d6ff..42b619651c2e 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -12,7 +12,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchPersonalDetails} from '@src/types/onyx/SearchResults'; import createRandomPolicy from '../utils/collections/policies'; -import createRandomReport from '../utils/collections/reports'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@components/ConfirmedRoute.tsx'); @@ -81,16 +80,13 @@ const createReportListItem = (type: ValueOf, from?: st // Helper function to wrap component with context const renderReportListItemHeader = (reportItem: ReportListItemType) => { - const mockReport = createRandomReport(Number(reportItem.reportID)); - return render( {/* @ts-expect-error - Disable TypeScript errors to simplify the test */} { describe('Test getAction', () => { test('Should return `Submit` action for transaction on policy with delayed submission and no violations', () => { - let action = SearchUIUtils.getAction(searchResults.data, `report_${reportID}`); + let action = SearchUIUtils.getAction(searchResults.data, {}, `report_${reportID}`); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); - action = SearchUIUtils.getAction(searchResults.data, `transactions_${transactionID}`); + action = SearchUIUtils.getAction(searchResults.data, {}, `transactions_${transactionID}`); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); test('Should return `Review` action for transaction on policy with delayed submission and with violations', () => { - let action = SearchUIUtils.getAction(searchResults.data, `report_${reportID2}`); + let action = SearchUIUtils.getAction(searchResults.data, allViolations, `report_${reportID2}`); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.REVIEW); - action = SearchUIUtils.getAction(searchResults.data, `transactions_${transactionID2}`); + action = SearchUIUtils.getAction(searchResults.data, allViolations, `transactions_${transactionID2}`); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.REVIEW); }); }); @@ -1071,10 +1091,10 @@ describe('SearchUIUtils', () => { Onyx.merge(ONYXKEYS.SESSION, {accountID: overlimitApproverAccountID}); searchResults.data[`policy_${policyID}`].role = CONST.POLICY.ROLE.USER; return waitForBatchedUpdates().then(() => { - let action = SearchUIUtils.getAction(searchResults.data, `report_${reportID2}`); + let action = SearchUIUtils.getAction(searchResults.data, allViolations, `report_${reportID2}`); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); - action = SearchUIUtils.getAction(searchResults.data, `transactions_${transactionID2}`); + action = SearchUIUtils.getAction(searchResults.data, allViolations, `transactions_${transactionID2}`); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); }); @@ -1201,7 +1221,7 @@ describe('SearchUIUtils', () => { }, }; return waitForBatchedUpdates().then(() => { - const action = SearchUIUtils.getAction(result.data, 'report_6523565988285061'); + const action = SearchUIUtils.getAction(result.data, allViolations, 'report_6523565988285061'); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.APPROVE); }); });