diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1d9db8c5138e5..cf7c6ae2eff7a 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5772,14 +5772,17 @@ const CONST = { }, /** - * Feature flag to disable the missingAttendees violation feature. - * Set to true to disable the feature if regressions are found. + * Feature flag to enable the missingAttendees violation feature. + * Currently enabled only on staging for testing. * When true: - * - Prevents new missingAttendees violations from being created - * - Removes existing missingAttendees violations from transaction lists - * - Hides "Require attendees" toggle in category settings + * - Enables new missingAttendees violations to be created + * - Shows existing missingAttendees violations in transaction lists + * - Shows "Require attendees" toggle in category settings + * Note: Config?.ENVIRONMENT is undefined in local dev when .env doesn't set it, so we treat undefined as dev */ - IS_ATTENDEES_REQUIRED_FEATURE_DISABLED: true, + // We can't use nullish coalescing for boolean comparison + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + IS_ATTENDEES_REQUIRED_ENABLED: !Config?.ENVIRONMENT || Config?.ENVIRONMENT === 'staging' || Config?.ENVIRONMENT === 'development', /** * Constants for types of violation. diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index d5bbfee50444b..ac2b3f81bf73e 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -60,6 +60,7 @@ import { isMerchantMissing, isScanRequest as isScanRequestUtil, } from '@libs/TransactionUtils'; +import {getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; import {hasInvoicingDetails} from '@userActions/Policy/Policy'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; @@ -457,6 +458,18 @@ function MoneyRequestConfirmationList({ [iouCategory, policyCategories, policy?.areRulesEnabled], ); + const isViolationFixed = getIsViolationFixed(formError, { + category: iouCategory, + tag: getTag(transaction), + taxCode: transaction?.taxCode, + policyCategories, + policyTagLists: policyTags, + policyTaxRates: policy?.taxRates?.taxes, + iouAttendees, + currentUserPersonalDetails, + isAttendeeTrackingEnabled: policy?.isAttendeeTrackingEnabled, + }); + useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -466,20 +479,21 @@ function MoneyRequestConfirmationList({ setFormError('iou.receiptScanningFailed'); return; } + // Check 1: If formError does NOT start with "violations.", clear it and return // Reset the form error whenever the screen gains or loses focus // but preserve violation-related errors since those represent real validation issues - // that can only be fixed by changing the underlying data + // that can only be resolved by fixing the underlying issue if (!formError.startsWith(CONST.VIOLATIONS_PREFIX)) { setFormError(''); return; } - // Clear missingAttendees violation if user fixed it by changing category or attendees - const isMissingAttendeesViolation = getIsMissingAttendeesViolation(policyCategories, iouCategory, iouAttendees, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled); - if (formError === 'violations.missingAttendees' && !isMissingAttendeesViolation) { + // Check 2: Only reached if formError STARTS with "violations." + // Clear any violation error if the user has fixed the underlying issue + if (isViolationFixed) { setFormError(''); } // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes - }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, iouCategory, iouAttendees, policyCategories, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled]); + }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed]); useEffect(() => { // We want this effect to run only when the transaction is moving from Self DM to a expense chat diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 9aa67760c07a1..14bf14256fbe0 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -1,5 +1,10 @@ import React, {useCallback, useContext, useMemo} from 'react'; import {View} from 'react-native'; +// We need direct access to useOnyx to fetch live policy data at render time +// without triggering the wrapper's additional logic, ensuring violations +// sync immediately when category settings change +// eslint-disable-next-line no-restricted-imports +import {useOnyx as originalUseOnyx} from 'react-native-onyx'; import {DelegateNoAccessContext} from '@components/DelegateNoAccessModalProvider'; import Icon from '@components/Icon'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -7,6 +12,7 @@ import BaseListItem from '@components/SelectionListWithSections/BaseListItem'; import type {ExpenseReportListItemProps, ExpenseReportListItemType, ListItem} from '@components/SelectionListWithSections/types'; import Text from '@components/Text'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -14,8 +20,12 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {handleActionButtonPress} from '@libs/actions/Search'; -import {isOpenExpenseReport, isProcessingReport} from '@libs/ReportUtils'; +import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {isInvoiceReport, isOpenExpenseReport, isProcessingReport} from '@libs/ReportUtils'; +import {isViolationDismissed, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isActionLoadingSelector} from '@src/selectors/ReportMetaData'; import type {Policy, Report} from '@src/types/onyx'; @@ -46,6 +56,12 @@ function ExpenseReportListItem({ const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); + const currentUserDetails = useCurrentUserPersonalDetails(); + + // Fetch live policy categories from Onyx to sync violations at render time + const [parentPolicy] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(reportItem.policyID)}`, {canBeMissing: true}); + const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportItem.reportID)}`, {canBeMissing: true}); + const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reportItem.policyID)}`, {canBeMissing: true}); const searchData = currentSearchResults?.data; @@ -66,6 +82,42 @@ function ExpenseReportListItem({ return reportItem.isDisabled ?? reportItem.isDisabledCheckbox; }, [reportItem.isDisabled, reportItem.isDisabledCheckbox]); + // Prefer live Onyx policy data over snapshot to ensure fresh policy settings + // like isAttendeeTrackingEnabled is not missing + // Use snapshotReport/snapshotPolicy as fallbacks to fix offline issues where + // newly created reports aren't in the search snapshot yet + const policyForViolations = parentPolicy ?? snapshotPolicy; + const reportForViolations = parentReport ?? snapshotReport; + + // Sync missingAttendees violation at render time for each transaction in the report + // This ensures violations show immediately when category settings change, without needing to click the row + const hasSyncedMissingAttendeesViolation = useMemo(() => { + if (!policyForViolations?.isAttendeeTrackingEnabled || policyForViolations?.type !== CONST.POLICY.TYPE.CORPORATE) { + return false; + } + + const isInvoice = isInvoiceReport(reportItem) || reportItem.type === CONST.REPORT.TYPE.INVOICE; + return reportItem?.transactions?.some((transaction) => { + const relevantViolations = (transaction.violations ?? []).filter( + (violation) => + !isViolationDismissed(transaction, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, policyForViolations) && + shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transaction), + ); + + const violations = syncMissingAttendeesViolation( + relevantViolations, + policyCategories, + transaction.category ?? '', + transaction.attendees, + currentUserDetails, + policyForViolations.isAttendeeTrackingEnabled ?? false, + policyForViolations.type === CONST.POLICY.TYPE.CORPORATE, + isInvoice, + ); + return violations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); + }); + }, [reportItem, policyCategories, policyForViolations, reportForViolations, currentUserDetails]); + const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); const handleOnButtonPress = useCallback(() => { @@ -133,8 +185,15 @@ function ExpenseReportListItem({ const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem); + // Show violation description if either: + // 1. Pre-computed hasVisibleViolations from search data, OR + // 2. Synced missingAttendees violation computed at render time (for stale data) + // We're using || instead of ?? because the variables are boolean + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const hasAnyVisibleViolations = reportItem?.hasVisibleViolations || hasSyncedMissingAttendeesViolation; + const getDescription = useMemo(() => { - if (!reportItem?.hasVisibleViolations || !shouldShowViolationDescription) { + if (!hasAnyVisibleViolations || !shouldShowViolationDescription) { return; } return ( @@ -150,7 +209,7 @@ function ExpenseReportListItem({ ); }, [ - reportItem?.hasVisibleViolations, + hasAnyVisibleViolations, shouldShowViolationDescription, styles.flexRow, styles.alignItemsCenter, diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 21331dd7d5cfc..44e3ba68575b3 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -23,6 +23,7 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import {handleActionButtonPress as handleActionButtonPressUtil} from '@libs/actions/Search'; import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {isInvoiceReport} from '@libs/ReportUtils'; import {isViolationDismissed, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -135,6 +136,7 @@ function TransactionListItem({ shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transactionItem), ); + const isInvoice = isInvoiceReport(reportForViolations) || reportForViolations.type === CONST.REPORT.TYPE.INVOICE; // Sync missingAttendees violation with current policy category settings (can be removed later when BE handles this) // Use live transaction data (attendees, category) to ensure we check against current state, not stale snapshot const attendeeOnyxViolations = syncMissingAttendeesViolation( @@ -145,6 +147,7 @@ function TransactionListItem({ currentUserDetails, policyForViolations?.isAttendeeTrackingEnabled ?? false, policyForViolations?.type === CONST.POLICY.TYPE.CORPORATE, + isInvoice, ); const transactionViolations = mergeProhibitedViolations(attendeeOnyxViolations); diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index e366a9f1ee8e2..4012925a6a47a 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -205,7 +205,7 @@ function TransactionItemRow({ if (!violations) { return undefined; } - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED) { return violations.filter((violation) => violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); } return violations; diff --git a/src/hooks/useTransactionViolations.ts b/src/hooks/useTransactionViolations.ts index eaaf3a5ac24db..e36e9f9b4afc1 100644 --- a/src/hooks/useTransactionViolations.ts +++ b/src/hooks/useTransactionViolations.ts @@ -30,7 +30,7 @@ function useTransactionViolations(transactionID?: string, shouldShowRterForSettl (violation: TransactionViolation) => !isViolationDismissed(transaction, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, iouReport, policy) && shouldShowViolation(iouReport, policy, violation.name, currentUserDetails.email ?? '', shouldShowRterForSettledReport, transaction) && - (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), + (CONST.IS_ATTENDEES_REQUIRED_ENABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), ), ), [transaction, transactionViolations, iouReport, policy, shouldShowRterForSettledReport, currentUserDetails.email, currentUserDetails.accountID], diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index 693497ce4135c..bf0a076fbe1c3 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -10,7 +10,7 @@ function formatRequiredFieldsTitle(translate: LocaleContextProps['translate'], p // Attendees field should show first when both are selected and attendee tracking is enabled // Respect feature flag - don't show attendees in title when feature is disabled - if (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED && isAttendeeTrackingEnabled && policyCategory.areAttendeesRequired) { + if (CONST.IS_ATTENDEES_REQUIRED_ENABLED && isAttendeeTrackingEnabled && policyCategory.areAttendeesRequired) { enabledFields.push(translate('iou.attendees')); } @@ -37,7 +37,7 @@ function getIsMissingAttendeesViolation( isAttendeeTrackingEnabled = false, ) { // Feature flag to quickly disable the attendees required feature - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED) { return false; } @@ -76,10 +76,12 @@ function syncMissingAttendeesViolation( userPersonalDetails: CurrentUserPersonalDetails, isAttendeeTrackingEnabled: boolean, isControlPolicy: boolean, + isInvoice = false, ): T[] { // Feature flag to quickly disable the attendees required feature // When disabled, remove any existing missingAttendees violations and don't add new ones - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + // Never add missingAttendees violation for invoices + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED || isInvoice) { return violations.filter((v) => v.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index f6ef0e032db46..e23f72026f9f0 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1506,7 +1506,7 @@ function getTransactionViolations( (violation) => !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, iouReport, policy), ) ?? []; - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED) { return violations.filter((violation) => violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); } @@ -1947,7 +1947,7 @@ function hasViolation( violation.type === CONST.VIOLATION_TYPES.VIOLATION && (showInReview === undefined || showInReview === (violation.showInReview ?? false)) && !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, iouReport, policy) && - (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), + (CONST.IS_ATTENDEES_REQUIRED_ENABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), ); } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index feb9264352081..3815f1d194b0c 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -4,6 +4,7 @@ import reject from 'lodash/reject'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; @@ -19,6 +20,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, ReportAction, Transaction, TransactionViolation, ViolationName} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; +import type ViolationFixParams from './types'; /** * Filters out receiptRequired violation when itemizedReceiptRequired is also present. @@ -235,6 +237,44 @@ function extractErrorMessages(errors: Errors | ReceiptErrors, errorActions: Repo return Array.from(uniqueMessages); } +/** + * Checks if a violation has been fixed by the user changing the underlying data. + * Returns true if the violation should be cleared, false if it should persist. + */ +function getIsViolationFixed(violationError: string, params: ViolationFixParams): boolean { + const {category, tag, taxCode, policyCategories, policyTagLists, policyTaxRates, iouAttendees, currentUserPersonalDetails, isAttendeeTrackingEnabled} = params; + + const violationValidators: Record boolean> = { + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY}`]: () => { + // Category is fixed if it exists and is enabled in policy + return !!(category && policyCategories?.[category]?.enabled); + }, + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.TAG_OUT_OF_POLICY}`]: () => { + // Tag is fixed if it's empty or matches a valid tag in policy + if (!tag) { + return true; + } + if (!policyTagLists) { + return false; + } + const hasEnabledTags = Object.values(policyTagLists).some((tagList) => tagList.tags && Object.values(tagList.tags).some((t) => t.enabled)); + const hasMatchingTag = Object.values(policyTagLists).some((tagList) => tagList.tags && Object.values(tagList.tags).some((t) => t.name === tag && t.enabled)); + return hasEnabledTags && hasMatchingTag; + }, + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.TAX_OUT_OF_POLICY}`]: () => { + // Tax is fixed if it's empty or exists in policy tax rates + return !taxCode || Object.keys(policyTaxRates ?? {}).some((key) => key === taxCode); + }, + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.MISSING_ATTENDEES}`]: () => { + // Attendees violation is fixed if getIsMissingAttendeesViolation returns false + return !getIsMissingAttendeesViolation(policyCategories, category, iouAttendees, currentUserPersonalDetails, isAttendeeTrackingEnabled); + }, + }; + + const validator = violationValidators[violationError]; + return validator ? validator() : false; +} + const ViolationsUtils = { /** * Checks a transaction for policy violations and returns an object with Onyx method, key and updated transaction @@ -557,7 +597,7 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_COMMENT}); } - const shouldProcessMissingAttendees = !CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED; + const shouldProcessMissingAttendees = CONST.IS_ATTENDEES_REQUIRED_ENABLED; if (shouldProcessMissingAttendees) { if (!hasMissingAttendeesViolation && shouldShowMissingAttendees) { @@ -783,12 +823,14 @@ const ViolationsUtils = { return ( !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, report, policy) && shouldShowViolation(report, policy, violation.name, currentUserEmail, true, transaction) && - (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES) + (CONST.IS_ATTENDEES_REQUIRED_ENABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES) ); }); }); }, }; +export {getIsViolationFixed}; +export type {ViolationFixParams}; export default ViolationsUtils; export {filterReceiptViolations}; diff --git a/src/libs/Violations/types.ts b/src/libs/Violations/types.ts new file mode 100644 index 0000000000000..2f51b4efe44ee --- /dev/null +++ b/src/libs/Violations/types.ts @@ -0,0 +1,17 @@ +import type {PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; + +type ViolationFixParams = { + category: string; + tag: string; + taxCode: string | undefined; + policyCategories: PolicyCategories | undefined; + policyTagLists: PolicyTagLists | undefined; + policyTaxRates: Record | undefined; + iouAttendees: Attendee[] | undefined; + currentUserPersonalDetails: CurrentUserPersonalDetails; + isAttendeeTrackingEnabled: boolean | undefined; +}; + +export default ViolationFixParams; diff --git a/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx index c6086e463ba1f..db9c11fce0648 100644 --- a/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx +++ b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx @@ -76,7 +76,7 @@ function CategoryRequiredFieldsPage({ - {isAttendeeTrackingEnabled && !CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED && ( + {isAttendeeTrackingEnabled && CONST.IS_ATTENDEES_REQUIRED_ENABLED && ( diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 7db4dee8cbc2a..4bc740c6f9579 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -195,7 +195,7 @@ function CategorySettingsPage({ setIsCannotDeleteOrDisableLastCategoryModalVisible, shouldPreventDisableOrDelete, policyData, - policyCategory.name, + policyCategory?.name, isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index fe8aaa523046d..3bcac3acd7bdf 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -2,7 +2,7 @@ import {beforeEach} from '@jest/globals'; import Onyx from 'react-native-onyx'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import {getTransactionViolations, hasWarningTypeViolation, isViolationDismissed} from '@libs/TransactionUtils'; -import ViolationsUtils, {filterReceiptViolations} from '@libs/Violations/ViolationsUtils'; +import ViolationsUtils, {filterReceiptViolations, getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, Transaction, TransactionViolation} from '@src/types/onyx'; @@ -728,13 +728,13 @@ describe('getViolationsOnyxData', () => { } as Report; }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should add missingAttendees violation when no attendees are present', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should add missingAttendees violation when no attendees are present', () => { transaction.comment = {attendees: []}; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should add missingAttendees violation when only owner is an attendee', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should add missingAttendees violation when only owner is an attendee', () => { transaction.comment = { attendees: [{email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}], }; @@ -782,7 +782,7 @@ describe('getViolationsOnyxData', () => { describe('optimistic / offline scenarios (iouReport is undefined)', () => { // In offline scenarios, iouReport is undefined so we can't get ownerAccountID. // The code falls back to using getCurrentUserEmail() to identify the owner by login/email. - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should correctly calculate violation when iouReport is undefined but attendees have matching email', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should correctly calculate violation when iouReport is undefined but attendees have matching email', () => { // When iouReport is undefined, we use getCurrentUserEmail() as fallback // If only the current user (matching MOCK_CURRENT_USER_EMAIL) is an attendee, violation should show transactionViolations = []; @@ -822,7 +822,7 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should preserve violation when only owner attendee remains (offline)', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should preserve violation when only owner attendee remains (offline)', () => { // If violation existed and only owner attendee remains, violation stays transactionViolations = [missingAttendeesViolation]; transaction.comment = { @@ -850,7 +850,7 @@ describe('getViolationsOnyxData', () => { jest.restoreAllMocks(); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)("should add missingAttendees violation when no attendees are present (can't identify owner)", () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)("should add missingAttendees violation when no attendees are present (can't identify owner)", () => { transactionViolations = []; transaction.comment = {attendees: []}; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); @@ -858,7 +858,7 @@ describe('getViolationsOnyxData', () => { expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should add missingAttendees violation when only 1 attendee exists (assumed to be owner)', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should add missingAttendees violation when only 1 attendee exists (assumed to be owner)', () => { transactionViolations = []; transaction.comment = { attendees: [{email: 'anyone@example.com', displayName: 'Someone', avatarUrl: ''}], @@ -1343,6 +1343,215 @@ describe('hasVisibleViolationsForUser', () => { }); }); +describe('getIsViolationFixed', () => { + const mockCurrentUserPersonalDetails = { + accountID: 1, + login: 'user@example.com', + email: 'user@example.com', + }; + + const defaultParams = { + category: '', + tag: '', + taxCode: undefined, + policyCategories: undefined, + policyTagLists: undefined, + policyTaxRates: undefined, + iouAttendees: undefined, + currentUserPersonalDetails: mockCurrentUserPersonalDetails, + isAttendeeTrackingEnabled: false, + }; + + const createPolicyTagList = (tagName: string, enabled: boolean) => ({ + Meals: { + name: 'Meals', + required: true, + orderWeight: 1, + tags: {[tagName]: {name: tagName, enabled}}, + }, + }); + + const createAttendee = (email: string) => ({ + email, + displayName: email.split('@')?.at(0) ?? '', + avatarUrl: '', + }); + + describe('violations.categoryOutOfPolicy', () => { + it('should return false when category is empty', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: '', + policyCategories: {Food: {name: 'Food', enabled: true}}, + }); + expect(result).toBe(false); + }); + + it('should return false when category is not in policy', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: 'Travel', + policyCategories: {Food: {name: 'Food', enabled: true}}, + }); + expect(result).toBe(false); + }); + + it('should return false when category exists but is disabled', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: 'Food', + policyCategories: {Food: {name: 'Food', enabled: false}}, + }); + expect(result).toBe(false); + }); + + it('should return true when category exists and is enabled', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: 'Food', + policyCategories: {Food: {name: 'Food', enabled: true}}, + }); + expect(result).toBe(true); + }); + }); + + describe('violations.tagOutOfPolicy', () => { + it('should return true when tag is empty', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: '', + }); + expect(result).toBe(true); + }); + + it('should return false when policyTagLists is undefined', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Lunch', + policyTagLists: undefined, + }); + expect(result).toBe(false); + }); + + it('should return false when tag is not in policy', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Breakfast', + policyTagLists: createPolicyTagList('Lunch', true), + }); + expect(result).toBe(false); + }); + + it('should return false when tag exists but is disabled', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Lunch', + policyTagLists: createPolicyTagList('Lunch', false), + }); + expect(result).toBe(false); + }); + + it('should return true when tag exists and is enabled', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Lunch', + policyTagLists: createPolicyTagList('Lunch', true), + }); + expect(result).toBe(true); + }); + }); + + describe('violations.taxOutOfPolicy', () => { + it('should return true when taxCode is empty', () => { + const result = getIsViolationFixed('violations.taxOutOfPolicy', { + ...defaultParams, + taxCode: undefined, + }); + expect(result).toBe(true); + }); + + it('should return false when taxCode is not in policy tax rates', () => { + const result = getIsViolationFixed('violations.taxOutOfPolicy', { + ...defaultParams, + taxCode: 'TAX_20', + policyTaxRates: {TAX_10: {name: '10%', value: '10'}}, + }); + expect(result).toBe(false); + }); + + it('should return true when taxCode exists in policy tax rates', () => { + const result = getIsViolationFixed('violations.taxOutOfPolicy', { + ...defaultParams, + taxCode: 'TAX_10', + policyTaxRates: {TAX_10: {name: '10%', value: '10'}}, + }); + expect(result).toBe(true); + }); + }); + + describe('violations.missingAttendees', () => { + it('should return true when attendee tracking is disabled', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: false, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + }); + expect(result).toBe(true); + }); + + it('should return true when category does not require attendees', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: false}}, + }); + expect(result).toBe(true); + }); + + it('should return false when no attendees are present and category requires them', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + iouAttendees: [], + }); + expect(result).toBe(false); + }); + + it('should return false when only the creator is an attendee', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + iouAttendees: [createAttendee('user@example.com')], + }); + expect(result).toBe(false); + }); + + it('should return true when there is a non-creator attendee', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + iouAttendees: [createAttendee('user@example.com'), createAttendee('other@example.com')], + }); + expect(result).toBe(true); + }); + }); + + describe('unknown violations', () => { + it('should return false for unknown violation types', () => { + const result = getIsViolationFixed('violations.unknownViolation', defaultParams); + expect(result).toBe(false); + }); + }); +}); + describe('filterReceiptViolations', () => { const itemizedReceiptRequiredViolation: TransactionViolation = { name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED,