diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 22cd2a11079b8..c74f24478f930 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -10,8 +10,8 @@ import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import type {PlatformStackRouteProp} from './Navigation/PlatformStackNavigation/types'; import type {TransactionDuplicateNavigatorParamList} from './Navigation/types'; -import {getOriginalMessage, getReportAction, isMessageDeleted, isMoneyRequestAction} from './ReportActionsUtils'; -import {isPaidGroupPolicy, isPaidGroupPolicyExpenseReport, isReportApproved, isSettled} from './ReportUtils'; +import {getOriginalMessage, getReportAction, getReportActions, isMessageDeleted, isMoneyRequestAction} from './ReportActionsUtils'; +import {hasActionsWithErrors, hasReportViolations, isPaidGroupPolicy, isPaidGroupPolicyExpenseReport, isReportApproved, isReportOwner, isSettled} from './ReportUtils'; import type {TransactionDetails} from './ReportUtils'; import StringUtils from './StringUtils'; import { @@ -135,6 +135,24 @@ function getViolationTranslatePath(violations: OnyxTypes.TransactionViolations, return isTooLong || hasViolationsAndHold || hasViolationsAndFieldErrors ? {translationPath: 'violations.reviewRequired'} : {text: violationMessage}; } +/** + * Extracts unique error messages from report actions. If no report or actions are found, + * it returns an empty array. It identifies the latest error in each action and filters out duplicates to + * ensure only unique error messages are returned. + */ +function getUniqueActionErrors(report: OnyxEntry) { + const reportActions = Object.values(report ? getReportActions(report) ?? {} : {}); + + const reportErrors = reportActions.map((reportAction) => { + const errors = reportAction.errors ?? {}; + const key = Object.keys(errors).sort().reverse().at(0) ?? ''; + const error = errors[key]; + return typeof error === 'string' ? error : ''; + }); + + return [...new Set(reportErrors)].filter((err) => err.length); +} + function getTransactionPreviewTextAndTranslationPaths({ iouReport, transaction, @@ -167,6 +185,7 @@ function getTransactionPreviewTextAndTranslationPaths({ const isScanning = hasReceipt(transaction) && isReceiptBeingScanned(transaction); const hasFieldErrors = hasMissingSmartscanFields(transaction); const hasViolationsOfTypeNotice = hasNoticeTypeViolation(transaction?.transactionID, violations, true) && isPaidGroupPolicy(iouReport); + const hasActionWithErrors = hasActionsWithErrors(iouReport?.reportID); const {amount: requestAmount, currency: requestCurrency} = transactionDetails; @@ -197,6 +216,11 @@ function getTransactionPreviewTextAndTranslationPaths({ } } + if (RBRMessage === undefined && hasActionWithErrors) { + const actionsWithErrors = getUniqueActionErrors(iouReport); + RBRMessage = actionsWithErrors.length > 1 ? {translationPath: 'violations.reviewRequired'} : {text: actionsWithErrors.at(0)}; + } + RBRMessage ??= {text: ''}; let previewHeaderText: TranslationPathOrText[] = [showCashOrCard]; @@ -307,7 +331,8 @@ function createTransactionPreviewConditionals({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasAnyViolations = hasViolationsOfTypeNotice || hasWarningTypeViolation(transaction?.transactionID, violations, true) || hasViolation(transaction, violations, true); const hasErrorOrOnHold = hasFieldErrors || (!isFullySettled && !isFullyApproved && isTransactionOnHold); - const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold; + const hasReportViolationsOrActionErrors = (isReportOwner(iouReport) && hasReportViolations(iouReport?.reportID)) || hasActionsWithErrors(iouReport?.reportID); + const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors; // When there are no settled transactions in duplicates, show the "Keep this one" button const shouldShowKeepButton = areThereDuplicates; @@ -345,5 +370,6 @@ export { createTransactionPreviewConditionals, getOriginalTransactionIfBillIsSplit, getViolationTranslatePath, + getUniqueActionErrors, }; export type {TranslationPathOrText}; diff --git a/src/libs/__mocks__/TransactionPreviewUtils.ts b/src/libs/__mocks__/TransactionPreviewUtils.ts index b1a0748af5d38..30eb80c44e049 100644 --- a/src/libs/__mocks__/TransactionPreviewUtils.ts +++ b/src/libs/__mocks__/TransactionPreviewUtils.ts @@ -36,5 +36,6 @@ export { getReviewNavigationRoute, getOriginalTransactionIfBillIsSplit, getViolationTranslatePath, + getUniqueActionErrors, } from '../TransactionPreviewUtils'; export {getIOUData}; diff --git a/tests/unit/TransactionPreviewUtils.test.ts b/tests/unit/TransactionPreviewUtils.test.ts index 7441112dd22c2..7806cc4a2b258 100644 --- a/tests/unit/TransactionPreviewUtils.test.ts +++ b/tests/unit/TransactionPreviewUtils.test.ts @@ -1,8 +1,11 @@ import {buildOptimisticIOUReport, buildOptimisticIOUReportAction} from '@libs/ReportUtils'; -import {createTransactionPreviewConditionals, getTransactionPreviewTextAndTranslationPaths} from '@libs/TransactionPreviewUtils'; +import {createTransactionPreviewConditionals, getTransactionPreviewTextAndTranslationPaths, getUniqueActionErrors, getViolationTranslatePath} from '@libs/TransactionPreviewUtils'; import {buildOptimisticTransaction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; +import * as ReportActionUtils from '@src/libs/ReportActionsUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; +import type {Report, ReportActions} from '@src/types/onyx'; +import {iouReportR14932 as mockedReport} from '../../__mocks__/reportData/reports'; const basicProps = { iouReport: buildOptimisticIOUReport(123, 234, 1000, '1', 'USD'), @@ -216,4 +219,90 @@ describe('TransactionPreviewUtils', () => { expect(result.shouldShowDescription).toBeTruthy(); }); }); + + describe('getViolationTranslatePath', () => { + const message = 'Message'; + const reviewRequired = {translationPath: 'violations.reviewRequired'}; + const longMessage = 'x'.repeat(CONST.REPORT_VIOLATIONS.RBR_MESSAGE_MAX_CHARACTERS_FOR_PREVIEW + 1); + + const mockViolations = (count: number) => + [ + {name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + {name: CONST.VIOLATIONS.CUSTOM_RULES, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ].slice(0, count); + + test('returns translationPath when there is at least one violation and transaction is on hold', () => { + expect(getViolationTranslatePath(mockViolations(1), false, message, true)).toEqual(reviewRequired); + }); + + test('returns translationPath if violation message is too long', () => { + expect(getViolationTranslatePath(mockViolations(1), false, longMessage, false)).toEqual(reviewRequired); + }); + + test('returns translationPath when there are multiple violations', () => { + expect(getViolationTranslatePath(mockViolations(2), false, message, false)).toEqual(reviewRequired); + }); + + test('returns translationPath when there is at least one violation and there are field errors', () => { + expect(getViolationTranslatePath(mockViolations(1), true, message, false)).toEqual(reviewRequired); + }); + + test('returns text when there are no violations, no hold, no field errors, and message is short', () => { + expect(getViolationTranslatePath(mockViolations(0), false, message, false)).toEqual({text: message}); + }); + + test('returns translationPath when there are no violations but message is too long', () => { + expect(getViolationTranslatePath(mockViolations(0), false, longMessage, false)).toEqual(reviewRequired); + }); + }); + + describe('getUniqueActionErrors', () => { + test('returns an empty array if there is no report or it is empty', () => { + expect(getUniqueActionErrors(undefined)).toEqual([]); + expect(getUniqueActionErrors({} as Report)).toEqual([]); + }); + + test('returns an empty array if there are no actions in the report', () => { + jest.spyOn(ReportActionUtils, 'getReportActions').mockReturnValue({}); + expect(getUniqueActionErrors(mockedReport)).toEqual([]); + }); + + test('returns unique error messages from report actions', () => { + const actions = { + /* eslint-disable @typescript-eslint/naming-convention */ + 1: {errors: {a: 'Error A', b: 'Error B'}}, + 2: {errors: {c: 'Error C', a: 'Error A2'}}, + 3: {errors: {a: 'Error A', d: 'Error D'}}, + /* eslint-enable @typescript-eslint/naming-convention */ + } as unknown as ReportActions; + jest.spyOn(ReportActionUtils, 'getReportActions').mockReturnValue(actions); + + const expectedErrors = ['Error B', 'Error C', 'Error D']; + expect(getUniqueActionErrors(mockedReport).sort()).toEqual(expectedErrors.sort()); + }); + + test('returns the latest error message if multiple errors exist under a single action', () => { + const actions = { + /* eslint-disable @typescript-eslint/naming-convention */ + 1: {errors: {z: 'Error Z2', a: 'Error A', f: 'Error Z'}}, + /* eslint-enable @typescript-eslint/naming-convention */ + } as unknown as ReportActions; + jest.spyOn(ReportActionUtils, 'getReportActions').mockReturnValue(actions); + + expect(getUniqueActionErrors(mockedReport)).toEqual(['Error Z2']); + }); + + test('filters out non-string error messages', () => { + const actions = { + /* eslint-disable @typescript-eslint/naming-convention */ + 1: {errors: {a: 404, b: 'Error B'}}, + 2: {errors: {c: null, d: 'Error D'}}, + /* eslint-enable @typescript-eslint/naming-convention */ + } as unknown as ReportActions; + jest.spyOn(ReportActionUtils, 'getReportActions').mockReturnValue(actions); + + expect(getUniqueActionErrors(mockedReport)).toEqual(['Error B', 'Error D']); + }); + }); });