diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index bf7da4cfcdd01..da79751573ad6 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9206,6 +9206,42 @@ function hasViolations( return transactions.some((transaction) => hasViolation(transaction, transactionViolations, currentUserEmailParam ?? '', currentUserAccountIDParam, report, policy, shouldShowInReview)); } +function hasVisibleReportFieldViolations(report: OnyxEntry, policy: OnyxEntry, reportViolations?: OnyxEntry): boolean { + if (!report || !policy?.fieldList || !policy?.areReportFieldsEnabled) { + return false; + } + + const isPaidGroupPolicyReport = isExpenseReport(report) && (policy?.type === CONST.POLICY.TYPE.CORPORATE || policy?.type === CONST.POLICY.TYPE.TEAM); + if (!isPaidGroupPolicyReport && !isInvoiceReport(report)) { + return false; + } + + // We only show the RBR to the submitter for expense reports + if (isPaidGroupPolicyReport && !isCurrentUserSubmitter(report)) { + return false; + } + + // Allow both open and processing reports to show RBR for field violations (expense reports only) + if (isPaidGroupPolicyReport && !isOpenOrProcessingReport(report)) { + return false; + } + + const {fieldsByName} = getReportFieldMaps(report, policy.fieldList); + + return Object.values(fieldsByName).some((field) => { + if (field.target !== report.type) { + return false; + } + if (shouldHideSingleReportField(field)) { + return false; + } + if (isReportFieldDisabledForUser(report, field, policy)) { + return false; + } + return !!getFieldViolation(reportViolations, field); + }); +} + /** * Checks to see if a report contains a violation of type `warning` */ @@ -13333,6 +13369,7 @@ export { hasSmartscanError, hasUpdatedTotal, hasViolations, + hasVisibleReportFieldViolations, hasWarningTypeViolations, hasNoticeTypeViolations, hasAnyViolations, diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 0e51a8278c039..36acae95574a8 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -1,6 +1,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {computeReportName} from '@libs/ReportNameUtils'; -import {generateIsEmptyReport, generateReportAttributes, isArchivedReport, isValidReport} from '@libs/ReportUtils'; +import {generateIsEmptyReport, generateReportAttributes, hasVisibleReportFieldViolations, isArchivedReport, isValidReport} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; import {hasKeyTriggeredCompute} from '@userActions/OnyxDerived/utils'; @@ -19,7 +19,8 @@ const prepareReportKeys = (keys: string[]) => { key .replace(ONYXKEYS.COLLECTION.REPORT_METADATA, ONYXKEYS.COLLECTION.REPORT) .replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT) - .replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ONYXKEYS.COLLECTION.REPORT), + .replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ONYXKEYS.COLLECTION.REPORT) + .replace(ONYXKEYS.COLLECTION.REPORT_VIOLATIONS, ONYXKEYS.COLLECTION.REPORT), ), ), ]; @@ -70,10 +71,11 @@ export default createOnyxDerivedValueConfig({ ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.COLLECTION.POLICY_TAGS, + ONYXKEYS.COLLECTION.REPORT_VIOLATIONS, ONYXKEYS.COLLECTION.REPORT_METADATA, ], compute: ( - [reports, preferredLocale, transactionViolations, reportActions, reportNameValuePairs, transactions, personalDetails, session, policies, policyTags], + [reports, preferredLocale, transactionViolations, reportActions, reportNameValuePairs, transactions, personalDetails, session, policies, policyTags, reportViolations], {currentValue, sourceValues}, ) => { // Check if display names changed when personal details are updated @@ -115,6 +117,7 @@ export default createOnyxDerivedValueConfig({ const reportMetadataUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_METADATA] ?? {}; const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS] ?? {}; const reportNameValuePairsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS] ?? {}; + const reportViolationsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_VIOLATIONS] ?? {}; const transactionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION]; const transactionViolationsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]; let dataToIterate = Object.keys(reports); @@ -139,6 +142,7 @@ export default createOnyxDerivedValueConfig({ ...Object.keys(reportMetadataUpdates), ...Object.keys(reportActionsUpdates), ...Object.keys(reportNameValuePairsUpdates), + ...Object.keys(reportViolationsUpdates), ...Array.from(reportUpdatesRelatedToReportActions), ]; @@ -220,6 +224,9 @@ export default createOnyxDerivedValueConfig({ isReportArchived, }); + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + const hasFieldViolations = hasVisibleReportFieldViolations(report, policy, reportViolations?.[`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${report.reportID}`]); + let brickRoadStatus; let actionBadge; let actionTargetReportActionID; @@ -227,7 +234,7 @@ export default createOnyxDerivedValueConfig({ report, chatReport, reportActionsList, - hasAnyViolations, + hasAnyViolations || hasFieldViolations, reportErrors, transactions, transactionViolations, diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index d12448d36d7c7..028ee038fd3ba 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -123,9 +123,9 @@ describe('OnyxDerived', () => { const transaction = createRandomTransaction(1); // When the report attributes are recomputed with both report and transaction updates - reportAttributes.compute([reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], {}); + reportAttributes.compute([reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], {}); const reportAttributesComputedValue = reportAttributes.compute( - [reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], + [reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], { sourceValues: { [ONYXKEYS.COLLECTION.REPORT]: { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 18600814b0da1..e659142e7f871 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -102,6 +102,7 @@ import { hasActionWithErrorsForTransaction, hasEmptyReportsForPolicy, hasReceiptError, + hasVisibleReportFieldViolations, isAllowedToApproveExpenseReport, isArchivedNonExpenseReport, isArchivedReport, @@ -14120,6 +14121,85 @@ describe('ReportUtils', () => { }); }); + describe('hasVisibleReportFieldViolations', () => { + const policyID = 'policy-field-violations'; + + const baseField: PolicyReportField = { + name: 'project', + fieldID: 'project_field', + defaultValue: '', + orderWeight: 1, + type: 'text', + deletable: true, + target: CONST.REPORT.TYPE.EXPENSE, + values: [], + keys: [], + externalIDs: [], + disabledOptions: [], + isTax: false, + }; + + const basePolicy = { + ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + areReportFieldsEnabled: true, + fieldList: { + [`expensify_${baseField.fieldID}`]: baseField, + }, + }; + + const expenseReport: Report = { + reportID: 'report-field-violations', + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: currentUserEmail}); + await waitForBatchedUpdates(); + }); + + it('should return false when policy does not have areReportFieldsEnabled enabled', () => { + const policyWithFieldsDisabled = {...basePolicy, areReportFieldsEnabled: false}; + + expect(hasVisibleReportFieldViolations(expenseReport, policyWithFieldsDisabled)).toBe(false); + }); + + it('should return false when the report is not an expense report or invoice report', () => { + const chatReport: Report = { + reportID: 'chat-report-field-violations', + type: CONST.REPORT.TYPE.CHAT, + policyID, + }; + + expect(hasVisibleReportFieldViolations(chatReport, basePolicy)).toBe(false); + }); + + it('should return true when expense report has a required field with no value', async () => { + const fieldWithNoValue: PolicyReportField = { + ...baseField, + value: null, + defaultValue: '', + }; + + const policyWithEmptyField = { + ...basePolicy, + fieldList: {[`expensify_${fieldWithNoValue.fieldID}`]: fieldWithNoValue}, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policyWithEmptyField); + await waitForBatchedUpdates(); + + expect(hasVisibleReportFieldViolations(expenseReport, policyWithEmptyField)).toBe(true); + }); + }); + describe('getAddExpenseDropdownOptions', () => { const mockTranslate: LocaleContextProps['translate'] = (path, ...params) => translate(CONST.LOCALES.EN, path, ...params); const mockIcons = {