diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 916a92a3233f..2f6c905515d4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -29,6 +29,7 @@ import {navigationRef} from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {getMoneyRequestSpendBreakdown, isIOUReport} from '@libs/ReportUtils'; import {compareValues} from '@libs/SearchUIUtils'; +import {getTransactionPendingAction} from '@libs/TransactionUtils'; import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransactionYear'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -122,6 +123,11 @@ function MoneyRequestReportTransactionList({ const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency); const shouldShowBreakdown = !!nonReimbursableSpend && !!reimbursableSpend; + const pendingActionsOpacity = useMemo(() => { + const pendingAction = transactions.some(getTransactionPendingAction); + return pendingAction && styles.opacitySemiTransparent; + }, [styles.opacitySemiTransparent, transactions]); + const {bind} = useHover(); const {isMouseDownOnInput, setMouseUp} = useMouseContext(); @@ -228,6 +234,7 @@ function MoneyRequestReportTransactionList({ )} {sortedTransactions.map((transaction) => { + const isPendingDelete = getTransactionPendingAction(transaction) === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; return ( {translate('common.total')} - + {convertToDisplayString(totalDisplaySpend, report?.currency)} diff --git a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx index 41c9358deee3..5828fd1ab1cc 100644 --- a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx @@ -1,34 +1,97 @@ -import React from 'react'; +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'; -function TransactionItemRowRBR({transaction, containerStyles}: {transaction: Transaction; containerStyles?: ViewStyle[]}) { +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 TransactionItemRowRBR({transaction, containerStyles}: TransactionItemRowRBRProps) { const styles = useThemeStyles(); const transactionViolations = useTransactionViolations(transaction?.transactionID); const {translate} = useLocalize(); const theme = useTheme(); - // 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. - const RBRMessages = transactionViolations - .map((violation) => { + 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(' '); - + }), + ].join(' '); return ( - transactionViolations.length > 0 && ( + RBRMessages.length > 0 && ( (null); const hasCategoryOrTag = !!transactionItem.category || !!transactionItem.tag; @@ -244,42 +246,61 @@ function TransactionItemRow({ onMouseEnter={bindHover.onMouseEnter} ref={viewRef} > - {shouldUseNarrowLayout ? ( - - - - {shouldShowCheckbox && ( - - { - onCheckboxPress(transactionItem.transactionID); - }} - accessibilityLabel={CONST.ROLE.CHECKBOX} - isChecked={isSelected} - /> - - )} - - - - - - - - + {shouldUseNarrowLayout ? ( + + + + {shouldShowCheckbox && ( + + { + onCheckboxPress(transactionItem.transactionID); + }} + accessibilityLabel={CONST.ROLE.CHECKBOX} + isChecked={isSelected} + /> + + )} + + - {isMerchantEmpty && ( - + + + + + + + {isMerchantEmpty && ( + + + + )} + + {!isMerchantEmpty && ( + + )} - {!isMerchantEmpty && ( - - - - - )} - - - - {hasCategoryOrTag && ( - - - - - )} - + + {hasCategoryOrTag && ( + + + + + )} + + + - - - - ) : ( - - - - - { - onCheckboxPress(transactionItem.transactionID); - }} - accessibilityLabel={CONST.ROLE.CHECKBOX} - isChecked={isSelected} - /> + + ) : ( + + + + + { + onCheckboxPress(transactionItem.transactionID); + }} + accessibilityLabel={CONST.ROLE.CHECKBOX} + isChecked={isSelected} + /> + + {columns?.map((column) => columnComponent[column])} - {columns?.map((column) => columnComponent[column])} + - - - - )} + + )} + ); } diff --git a/src/languages/en.ts b/src/languages/en.ts index 281966501803..b0baff751d94 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1101,6 +1101,7 @@ const translations = { genericUnholdExpenseFailureMessage: 'Unexpected error taking this expense off hold. Please try again later.', receiptDeleteFailureError: 'Unexpected error deleting this receipt. Please try again later.', receiptFailureMessage: 'There was an error uploading your receipt. Please ', + receiptFailureMessageShort: 'There was an error uploading your receipt.', tryAgainMessage: 'try again ', saveFileMessage: ' save the receipt', uploadLaterMessage: ' to upload later.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 100da0352d18..28b96f8afb5c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1099,6 +1099,7 @@ const translations = { genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura. Por favor, inténtalo de nuevo más tarde.', receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Por favor, vuelve a intentarlo más tarde.', receiptFailureMessage: 'Hubo un error al cargar tu recibo. Por favor, ', + receiptFailureMessageShort: 'Hubo un error al cargar tu recibo.', tryAgainMessage: 'inténtalo de nuevo ', saveFileMessage: ' guarda el recibo', uploadLaterMessage: ' para cargarlo más tarde.', diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 5a1699a22425..6984159e2d08 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -44,6 +44,7 @@ import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionCustomUnit, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -1545,6 +1546,17 @@ function shouldShowRTERViolationMessage(transactions?: Transaction[]) { return transactions?.length === 1 && hasPendingUI(transactions?.at(0), getTransactionViolations(transactions?.at(0)?.transactionID, allTransactionViolations)); } +/** + * Return transactions pending action. + */ +function getTransactionPendingAction(transaction: OnyxEntry): PendingAction { + if (transaction?.pendingAction) { + return transaction.pendingAction; + } + const hasPendingFields = Object.keys(transaction?.pendingFields ?? {}).length > 0; + return hasPendingFields ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null; +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -1642,6 +1654,7 @@ export { isPendingCardOrScanningTransaction, getTransactionOrDraftTransaction, checkIfShouldShowMarkAsCashButton, + getTransactionPendingAction, }; export type {TransactionChanges}; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 8b7a0dbcc400..623d3741a7b6 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -484,4 +484,24 @@ describe('TransactionUtils', () => { }); }); }); + describe('getTransactionPendingAction', () => { + it.each([ + ['when pendingAction is null', null, null], + ['when pendingAction is delete', CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE], + ['when pendingAction is add', CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD], + ])('%s', (_description, pendingAction, expected) => { + const transaction = generateTransaction({pendingAction}); + const result = TransactionUtils.getTransactionPendingAction(transaction); + expect(result).toEqual(expected); + }); + it('when pendingAction is update', () => { + const pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + const transaction = generateTransaction({ + pendingFields: {amount: pendingAction}, + pendingAction: null, + }); + const result = TransactionUtils.getTransactionPendingAction(transaction); + expect(result).toEqual(pendingAction); + }); + }); });