diff --git a/src/CONST.ts b/src/CONST.ts index 3675ecc9af6c..e03f42b282e4 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4094,6 +4094,14 @@ const CONST = { }, REVIEW_DUPLICATES_ORDER: ['merchant', 'category', 'tag', 'description', 'taxCode', 'billable', 'reimbursable'], + REPORT_VIOLATIONS: { + FIELD_REQUIRED: 'fieldRequired', + }, + + REPORT_VIOLATIONS_EXCLUDED_FIELDS: { + TEXT_TITLE: 'text_title', + }, + /** Context menu types */ CONTEXT_MENU_TYPES: { LINK: 'LINK', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0d93cbca2194..62c32e15f3b6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -420,6 +420,7 @@ const ONYXKEYS = { REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', REPORT_USER_IS_TYPING: 'reportUserIsTyping_', REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', + REPORT_VIOLATIONS: 'reportViolations_', SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', TRANSACTION_VIOLATIONS: 'transactionViolations_', @@ -695,6 +696,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean; [ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: OnyxTypes.ReportUserIsTyping; [ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean; + [ONYXKEYS.COLLECTION.REPORT_VIOLATIONS]: OnyxTypes.ReportViolations; [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index bb5fdb580aa7..2afd9e10b80c 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -36,6 +36,7 @@ function OptionRowLHNData({ const optionItemRef = useRef(); const shouldDisplayViolations = canUseViolations && ReportUtils.shouldDisplayTransactionThreadViolations(fullReport, transactionViolations, parentReportAction); + const shouldDisplayReportViolations = ReportUtils.isReportOwner(fullReport) && ReportUtils.hasReportViolations(reportID); const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! @@ -46,7 +47,7 @@ function OptionRowLHNData({ preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT, policy, parentReportAction, - hasViolations: !!shouldDisplayViolations, + hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations, transactionViolations, }); if (deepEqual(item, optionItemRef.current)) { @@ -71,6 +72,7 @@ function OptionRowLHNData({ transactionViolations, canUseViolations, receiptTransactions, + shouldDisplayReportViolations, ]); return ( diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 740167292515..66db68d9c4e5 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -2,6 +2,7 @@ import {Str} from 'expensify-common'; import React, {useMemo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -19,6 +20,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import variables from '@styles/variables'; import * as reportActions from '@src/libs/actions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; @@ -62,6 +64,8 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo StyleUtils.getColorStyle(theme.textSupporting), ]; + const [violations] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${report.reportID}`); + const sortedPolicyReportFields = useMemo((): PolicyReportField[] => { const fields = ReportUtils.getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); return fields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight); @@ -90,6 +94,9 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID); + const violation = ReportUtils.getFieldViolation(violations, reportField); + const violationTranslation = ReportUtils.getFieldViolationTranslation(reportField, violation); + return ( {}} hoverAndPressStyle={false} titleWithTooltips={[]} + brickRoadIndicator={violation ? 'error' : undefined} + errorText={violationTranslation} /> ); diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 91b1d30ab51a..7a527610422b 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -155,6 +155,7 @@ function ReportPreview({ hasMissingSmartscanFields || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (canUseViolations && (ReportUtils.hasViolations(iouReportID, transactionViolations) || ReportUtils.hasWarningTypeViolations(iouReportID, transactionViolations))) || + (ReportUtils.isReportOwner(iouReport) && ReportUtils.hasReportViolations(iouReportID)) || ReportUtils.hasActionsWithErrors(iouReportID); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ({...ReceiptUtils.getThumbnailAndImageURIs(transaction), transaction})); diff --git a/src/languages/en.ts b/src/languages/en.ts index 41670f7e6947..6d35b8bf148a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4009,6 +4009,9 @@ export default { confirmDuplicatesInfo: `The duplicate requests you don't keep will be held for the member to delete`, hold: 'Hold', }, + reportViolations: { + [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} is required`, + }, violationDismissal: { rter: { manual: 'marked this receipt as cash.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5c5b58880dce..be8bb9367306 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4530,6 +4530,9 @@ export default { confirmDuplicatesInfo: 'Los duplicados que no conserves se guardarán para que el usuario los elimine', hold: 'Bloqueado', }, + reportViolations: { + [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} es obligatorio`, + }, violationDismissal: { rter: { manual: 'marcó el recibo como pagado en efectivo.', diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 655cb6600fd0..5af160f6efcb 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -34,6 +34,8 @@ import type { ReportAction, ReportMetadata, ReportNameValuePairs, + ReportViolationName, + ReportViolations, Session, Task, Transaction, @@ -578,6 +580,18 @@ Onyx.connect({ }, }); +let allReportsViolations: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_VIOLATIONS, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + return; + } + allReportsViolations = value; + }, +}); + let isFirstTimeNewExpensifyUser = false; Onyx.connect({ key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, @@ -5425,6 +5439,11 @@ function hasWarningTypeViolations(reportID: string, transactionViolations: OnyxC return transactions.some((transaction) => TransactionUtils.hasWarningTypeViolation(transaction.transactionID, transactionViolations)); } +function hasReportViolations(reportID: string) { + const reportViolations = allReportsViolations?.[`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${reportID}`]; + return Object.values(reportViolations ?? {}).some((violations) => !isEmptyObject(violations)); +} + /** * Checks if #admins room chan be shown * We show #admin rooms when a) More than one admin exists or b) There exists policy audit log for review. @@ -7158,6 +7177,44 @@ function getChatUsedForOnboarding(): OnyxEntry { return Object.values(ReportConnection.getAllReports() ?? {}).find(isChatUsedForOnboarding); } +/** + * Checks if given field has any violations and returns name of the first encountered one + */ +function getFieldViolation(violations: OnyxEntry, reportField: PolicyReportField): ReportViolationName | undefined { + if (!violations || !reportField) { + return undefined; + } + + return Object.values(CONST.REPORT_VIOLATIONS).find((violation) => !!violations[violation] && violations[violation][reportField.fieldID]); +} + +/** + * Returns translation for given field violation + */ +function getFieldViolationTranslation(reportField: PolicyReportField, violation?: ReportViolationName): string { + if (!violation) { + return ''; + } + + switch (violation) { + case 'fieldRequired': + return Localize.translateLocal('reportViolations.fieldRequired', reportField.name); + default: + return ''; + } +} + +/** + * Returns all violations for report + */ +function getReportViolations(reportID: string): ReportViolations | undefined { + if (!allReportsViolations) { + return undefined; + } + + return allReportsViolations[`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${reportID}`]; +} + function findPolicyExpenseChatByPolicyID(policyID: string): OnyxEntry { return Object.values(ReportConnection.getAllReports() ?? {}).find((report) => isPolicyExpenseChat(report) && report?.policyID === policyID); } @@ -7465,6 +7522,9 @@ export { createDraftWorkspaceAndNavigateToConfirmationScreen, isChatUsedForOnboarding, getChatUsedForOnboarding, + getFieldViolationTranslation, + getFieldViolation, + getReportViolations, findPolicyExpenseChatByPolicyID, getIntegrationIcon, canBeExported, @@ -7473,6 +7533,7 @@ export { getMostRecentlyVisitedReport, getReport, getReportNameValuePairs, + hasReportViolations, }; export type { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 5c908129a53e..30f0ccfae328 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -853,6 +853,31 @@ function buildOnyxDataForMoneyRequest( }); } + const missingFields: OnyxTypes.ReportFieldsViolations = {}; + const excludedFields = Object.values(CONST.REPORT_VIOLATIONS_EXCLUDED_FIELDS) as string[]; + + Object.values(iouReport.fieldList ?? {}).forEach((field) => { + if (excludedFields.includes(field.fieldID) || !!field.value) { + return; + } + // in case of missing field violation the empty object is indicator. + missingFields[field.fieldID] = {}; + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, + value: { + fieldRequired: missingFields, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, + value: null, + }); + // We don't need to compute violations unless we're on a paid policy if (!policy || !PolicyUtils.isPaidGroupPolicy(policy)) { return [optimisticData, successData, failureData]; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d31a1af6048d..517c597d2657 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1860,6 +1860,8 @@ function clearReportFieldErrors(reportID: string, reportField: PolicyReportField function updateReportField(reportID: string, reportField: PolicyReportField, previousReportField: PolicyReportField) { const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID); + const reportViolations = ReportUtils.getReportViolations(reportID); + const fieldViolation = ReportUtils.getFieldViolation(reportViolations, reportField); const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? []; const optimisticData: OnyxUpdate[] = [ @@ -1877,6 +1879,18 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre }, ]; + if (fieldViolation) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${reportID}`, + value: { + [fieldViolation]: { + [reportField.fieldID]: null, + }, + }, + }); + } + if (reportField.type === 'dropdown' && reportField.value) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/types/onyx/ReportViolation.ts b/src/types/onyx/ReportViolation.ts new file mode 100644 index 000000000000..fae385cc674d --- /dev/null +++ b/src/types/onyx/ReportViolation.ts @@ -0,0 +1,21 @@ +import type {EmptyObject, ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +/** + * Names of violations. + * Derived from `CONST.VIOLATIONS` to maintain a single source of truth. + */ +type ReportViolationName = ValueOf; + +/** + * Keys of this object are IDs of field that has violations + */ +type ReportFieldsViolations = Record; + +/** + * Report Violation model + */ +type ReportViolations = Record; + +export type {ReportViolationName, ReportFieldsViolations}; +export default ReportViolations; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 4e4ad69ec752..8184dea41236 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -68,6 +68,8 @@ import type ReportMetadata from './ReportMetadata'; import type ReportNameValuePairs from './ReportNameValuePairs'; import type ReportNextStep from './ReportNextStep'; import type ReportUserIsTyping from './ReportUserIsTyping'; +import type {ReportFieldsViolations, ReportViolationName} from './ReportViolation'; +import type ReportViolations from './ReportViolation'; import type Request from './Request'; import type Response from './Response'; import type ReviewDuplicates from './ReviewDuplicates'; @@ -163,6 +165,9 @@ export type { ReportActionsDrafts, ReportMetadata, ReportNextStep, + ReportViolationName, + ReportViolations, + ReportFieldsViolations, Request, Response, ScreenShareRequest,