Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions src/libs/TransactionPreviewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<OnyxTypes.Report>) {
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);
Comment on lines +144 to +153
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we are getting errors from each report action and showing it on all transactions on that report. It caused #64531

}

function getTransactionPreviewTextAndTranslationPaths({
iouReport,
transaction,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -345,5 +370,6 @@ export {
createTransactionPreviewConditionals,
getOriginalTransactionIfBillIsSplit,
getViolationTranslatePath,
getUniqueActionErrors,
};
export type {TranslationPathOrText};
1 change: 1 addition & 0 deletions src/libs/__mocks__/TransactionPreviewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ export {
getReviewNavigationRoute,
getOriginalTransactionIfBillIsSplit,
getViolationTranslatePath,
getUniqueActionErrors,
} from '../TransactionPreviewUtils';
export {getIOUData};
91 changes: 90 additions & 1 deletion tests/unit/TransactionPreviewUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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'),
Expand Down Expand Up @@ -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']);
});
});
});
Loading