diff --git a/assets/images/receipt-search.svg b/assets/images/receipt-search.svg new file mode 100644 index 000000000000..bbf36cf9b1df --- /dev/null +++ b/assets/images/receipt-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f22984a038b1..031fc00df00a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1214,6 +1214,7 @@ const ONYXKEYS = { TODOS: 'todos', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID: 'openAndSubmittedReportsByPolicyID', + FLAGGED_EXPENSES: 'flaggedExpenses', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1707,6 +1708,7 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: OnyxTypes.OpenAndSubmittedReportsByPolicyIDDerivedValue; + [ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: OnyxTypes.FlaggedExpensesDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 078f2de84e42..219348dc4a20 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -207,6 +207,7 @@ import ReceiptMultiple from '@assets/images/receipt-multiple.svg'; import ReceiptPlaceholderPlus from '@assets/images/receipt-placeholder-plus.svg'; import ReceiptPlus from '@assets/images/receipt-plus.svg'; import ReceiptScan from '@assets/images/receipt-scan.svg'; +import ReceiptSearch from '@assets/images/receipt-search.svg'; import ReceiptSlash from '@assets/images/receipt-slash.svg'; import Receipt from '@assets/images/receipt.svg'; import RemoveMembers from '@assets/images/remove-members.svg'; @@ -451,6 +452,7 @@ const Expensicons = { ReceiptPlaceholderPlus, ReceiptPlus, ReceiptScan, + ReceiptSearch, ReceiptSlash, RemoveMembers, ReportCopy, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 9aa36783c1a3..8b8f8948b3e0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -20,7 +20,6 @@ import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import Text from '@components/Text'; -import {useWideRHPActions} from '@components/WideRHPContextProvider'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -28,6 +27,7 @@ import useHandleSelectionMode from '@hooks/useHandleSelectionMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -38,14 +38,13 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {setOptimisticTransactionThread} from '@libs/actions/Report'; import {getReportLayoutGroupBy, getReportLayoutSelection, setReportLayout} from '@libs/actions/ReportLayout'; import {clearActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import {resolveTransactionCardFields} from '@libs/CardUtils'; import {hasNonReimbursableTransactions, isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import {navigationRef} from '@libs/Navigation/Navigation'; import {isPolicyTaxEnabled} from '@libs/PolicyUtils'; -import {getIOUActionForTransactionID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {groupTransactionsByCategory, groupTransactionsByTag} from '@libs/ReportLayoutUtils'; import { canAddTransaction, @@ -68,9 +67,7 @@ import {getTransactionPendingAction, getVisibleTransactionViolations, isTransact import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransactionYear'; import isReportOpenInSuperWideRHP from '@navigation/helpers/isReportOpenInSuperWideRHP'; import Navigation from '@navigation/Navigation'; -import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; -import {createTransactionThreadReport} from '@userActions/Report'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -151,8 +148,6 @@ type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { shouldBeHighlighted?: boolean; }; -type ReportScreenNavigationProps = ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; - type SortedTransactions = { sortBy: SortableColumnName; sortOrder: SortOrder; @@ -180,7 +175,7 @@ function MoneyRequestReportTransactionList({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); - const {markReportIDAsExpense} = useWideRHPActions(); + const navigateToTransactionThread = useNavigateToTransactionThread(); const [isModalVisible, setIsModalVisible] = useState(false); const [selectedTransactionID, setSelectedTransactionID] = useState(''); const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); @@ -206,8 +201,6 @@ function MoneyRequestReportTransactionList({ const [reportLayoutOption] = useOnyx(ONYXKEYS.NVP_REPORT_LAYOUT_OPTION); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [reportDetailsColumns] = useOnyx(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [betas] = useOnyx(ONYXKEYS.BETAS); const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); @@ -554,44 +547,15 @@ function MoneyRequestReportTransactionList({ */ const navigateToTransaction = useCallback( (activeTransactionID: string) => { - const iouAction = getIOUActionForTransactionID(reportActions, activeTransactionID); - const backTo = Navigation.getActiveRoute(); - let reportIDToNavigate = iouAction?.childReportID; - - const routeParams = { - reportID: reportIDToNavigate, - backTo, - } as ReportScreenNavigationProps; - - if (!reportIDToNavigate) { - const transaction = sortedTransactions.find((t) => t.transactionID === activeTransactionID); - const transactionThreadReport = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserDetails.email ?? '', - currentUserAccountID: currentUserDetails.accountID, - betas, - iouReport: report, - iouReportAction: iouAction, - transaction, - }); - if (transactionThreadReport) { - reportIDToNavigate = transactionThreadReport.reportID; - routeParams.reportID = reportIDToNavigate; - } - } else { - setOptimisticTransactionThread(reportIDToNavigate, report?.reportID, iouAction?.reportActionID, report?.policyID); - } - - // Single transaction report will open in RHP, and we need to find every other report ID for the rest of transactions - // to display prev/next arrows in RHP for navigation - use visual order from grouped transactions - setActiveTransactionIDs(visualOrderTransactionIDs).then(() => { - if (reportIDToNavigate) { - markReportIDAsExpense(reportIDToNavigate); - } - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(routeParams)); + navigateToTransactionThread({ + transactionID: activeTransactionID, + reportActions, + report, + transaction: sortedTransactions.find((t) => t.transactionID === activeTransactionID), + siblingTransactionIDs: visualOrderTransactionIDs, }); }, - [reportActions, visualOrderTransactionIDs, sortedTransactions, report, markReportIDAsExpense, introSelected, betas, currentUserDetails.email, currentUserDetails.accountID], + [navigateToTransactionThread, reportActions, sortedTransactions, report, visualOrderTransactionIDs], ); const {amountColumnSize, dateColumnSize, taxAmountColumnSize} = useMemo(() => { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index b4c41f0352ed..5c0d06ffb66c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -93,7 +93,8 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR }; }, [nextTransactionID, parentReportActions, prevTransactionID, transactionIDsList]); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentTransaction?.reportID}`); + const [prevParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevTransaction?.reportID}`); + const [nextParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextTransaction?.reportID}`); const [prevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); const [nextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); @@ -132,7 +133,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR } // We know that the next thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. if (!nextThreadReport && nextThreadReportID) { - setOptimisticTransactionThread(nextThreadReportID, parentReport?.reportID, nextParentReportAction?.reportActionID, parentReport?.policyID); + setOptimisticTransactionThread(nextThreadReportID, nextParentReport?.reportID, nextParentReportAction?.reportActionID, nextParentReport?.policyID); } // The transaction thread doesn't exist yet, so we should create it if (!nextThreadReportID) { @@ -141,7 +142,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR currentUserLogin: currentUserEmail ?? '', currentUserAccountID, betas, - iouReport: parentReport, + iouReport: nextParentReport, iouReportAction: nextParentReportAction, transaction: nextTransaction, }); @@ -168,7 +169,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR } // We know that the previous thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. if (!prevThreadReport && prevThreadReportID) { - setOptimisticTransactionThread(prevThreadReportID, parentReport?.reportID, prevParentReportAction?.reportActionID, parentReport?.policyID); + setOptimisticTransactionThread(prevThreadReportID, prevParentReport?.reportID, prevParentReportAction?.reportActionID, prevParentReport?.policyID); } // The transaction thread doesn't exist yet, so we should create it if (!prevThreadReportID) { @@ -177,7 +178,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR currentUserLogin: currentUserEmail ?? '', currentUserAccountID, betas, - iouReport: parentReport, + iouReport: prevParentReport, iouReportAction: prevParentReportAction, transaction: prevTransaction, }); diff --git a/src/hooks/useNavigateToTransactionThread.ts b/src/hooks/useNavigateToTransactionThread.ts new file mode 100644 index 000000000000..914a27ffdc4d --- /dev/null +++ b/src/hooks/useNavigateToTransactionThread.ts @@ -0,0 +1,87 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {useWideRHPActions} from '@components/WideRHPContextProvider'; +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import Navigation from '@libs/Navigation/Navigation'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Report, ReportAction, Transaction} from '@src/types/onyx'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useOnyx from './useOnyx'; + +type NavigateToTransactionThreadParams = { + /** The transaction whose thread should be opened */ + transactionID: string; + + /** Report actions of the parent (IOU/expense) report, used to resolve the IOU action and its thread */ + reportActions: ReportAction[]; + + /** The parent (IOU/expense) report that owns the transaction */ + report: OnyxEntry; + + /** The transaction being opened, used to build the thread optimistically when it doesn't exist yet */ + transaction: OnyxEntry; + + /** Ordered list of sibling transaction IDs used to drive the prev/next carousel in the thread RHP */ + siblingTransactionIDs: string[]; + + /** Route to return to when navigating back; defaults to the current active route */ + backTo?: string; +}; + +/** + * Shared navigation algorithm for opening a transaction thread (single-expense RHP view with the + * prev/next carousel). It resolves the IOU action's `childReportID`, creates the thread optimistically + * when it doesn't exist yet, seeds the sibling transaction IDs for the carousel, and navigates to the + * SEARCH_REPORT route. + * + * Callers are responsible for gathering their own `reportActions`, `report`, `transaction`, and + * `siblingTransactionIDs` because the data sources differ per screen. + */ +function useNavigateToTransactionThread() { + const {markReportIDAsExpense} = useWideRHPActions(); + const currentUserDetails = useCurrentUserPersonalDetails(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + + return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, backTo}: NavigateToTransactionThreadParams) => { + const iouAction = getIOUActionForTransactionID(reportActions, transactionID); + const resolvedBackTo = backTo ?? Navigation.getActiveRoute(); + let reportIDToNavigate = iouAction?.childReportID; + + const routeParams: {reportID: string | undefined; reportActionID?: string; backTo?: string} = { + reportID: reportIDToNavigate, + backTo: resolvedBackTo, + }; + + if (!reportIDToNavigate) { + const transactionThreadReport = createTransactionThreadReport({ + introSelected, + currentUserLogin: currentUserDetails.email ?? '', + currentUserAccountID: currentUserDetails.accountID, + betas, + iouReport: report, + iouReportAction: iouAction, + transaction, + }); + if (transactionThreadReport) { + reportIDToNavigate = transactionThreadReport.reportID; + routeParams.reportID = reportIDToNavigate; + } + } else { + setOptimisticTransactionThread(reportIDToNavigate, report?.reportID, iouAction?.reportActionID, report?.policyID); + } + + // Single transaction report opens in RHP. We seed every sibling transaction ID so the RHP can + // display prev/next arrows for navigation between expenses. + setActiveTransactionIDs(siblingTransactionIDs).then(() => { + if (reportIDToNavigate) { + markReportIDAsExpense(reportIDToNavigate); + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(routeParams)); + }); + }; +} + +export default useNavigateToTransactionThread; diff --git a/src/languages/de.ts b/src/languages/de.ts index 80fc275a1192..99ed5657536b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1015,6 +1015,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'Alles erledigt', f1FlagsDescription: 'Sie haben alle offenen Aufgaben abgeschlossen.', }, + reviewExpenses: ({count}: {count: number}) => `Überprüfen Sie ${count} ${count === 1 ? 'Ausgabe' : 'Spesen'}`, }, upcomingTravel: 'Bevorstehende Reisen', upcomingTravelSection: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 7a591d68824b..a25ed5e174fd 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1053,6 +1053,7 @@ const translations = { menuItemDescription: 'See what Expensify can do in 2 min', }, forYouSection: { + reviewExpenses: ({count}: {count: number}) => `Review ${count} ${count === 1 ? 'expense' : 'expenses'}`, submit: ({count}: {count: number}) => `Submit ${count} ${count === 1 ? 'report' : 'reports'}`, approve: ({count}: {count: number}) => `Approve ${count} ${count === 1 ? 'report' : 'reports'}`, pay: ({count}: {count: number}) => `Pay ${count} ${count === 1 ? 'report' : 'reports'}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index ce3647332029..829eb7ac7dd7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -997,6 +997,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'Todo al día', f1FlagsDescription: 'Has completado todas las tareas pendientes.', }, + reviewExpenses: ({count}: {count: number}) => `Revisa ${count} ${count === 1 ? 'gasto' : 'gastos'}`, }, gettingStartedSection: { title: 'Primeros pasos', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b6b5208996d1..52fcbab1638b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1018,6 +1018,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'Tout est à jour', f1FlagsDescription: 'Vous avez terminé toutes les tâches en cours.', }, + reviewExpenses: ({count}: {count: number}) => `Examiner ${count} ${count === 1 ? 'dépense' : 'dépenses'}`, }, upcomingTravel: 'Voyages à venir', upcomingTravelSection: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 03739d9dac38..169332f90361 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1016,6 +1016,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'Tutto a posto', f1FlagsDescription: 'Hai completato tutte le attività in sospeso.', }, + reviewExpenses: ({count}: {count: number}) => `Esamina ${count} ${count === 1 ? 'spesa' : 'spese'}`, }, upcomingTravel: 'Prossimi viaggi', upcomingTravelSection: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0c9cfba5500c..1e0f17dec629 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1002,6 +1002,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'すべて確認済みです', f1FlagsDescription: 'すべての未処理の To-do が完了しました。', }, + reviewExpenses: ({count}: {count: number}) => `${count} 件の${count === 1 ? '経費' : '経費'}を確認`, }, upcomingTravel: '今後の出張', upcomingTravelSection: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9437a6e7e3bc..2241741eb617 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1014,6 +1014,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'Helemaal bij', f1FlagsDescription: "Je hebt alle openstaande to-do's afgerond.", }, + reviewExpenses: ({count}: {count: number}) => `Beoordeel ${count} ${count === 1 ? 'uitgave' : 'uitgaven'}`, }, upcomingTravel: 'Aankomende reizen', upcomingTravelSection: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 17f55a003160..cf1c270a3412 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1016,6 +1016,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'Wszystko nadrobione', f1FlagsDescription: 'Ukończyłeś wszystkie zaległe zadania.', }, + reviewExpenses: ({count}: {count: number}) => `Przejrzyj ${count} ${count === 1 ? 'wydatek' : 'wydatki'}`, }, upcomingTravel: 'Nadchodząca podróż', upcomingTravelSection: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 7cfa67f9d691..522c555b31c5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1014,6 +1014,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: 'Tudo em dia', f1FlagsDescription: 'Você concluiu todas as tarefas pendentes.', }, + reviewExpenses: ({count}: {count: number}) => `Revisar ${count} ${count === 1 ? 'despesa' : 'despesas'}`, }, upcomingTravel: 'Próximas viagens', upcomingTravelSection: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c60fe5de58fc..dd188d44f80c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -985,6 +985,7 @@ const translations: TranslationDeepObject = { f1FlagsTitle: '全部完成', f1FlagsDescription: '你已完成所有未完成的待办事项。', }, + reviewExpenses: ({count}: {count: number}) => `审核 ${count} ${count === 1 ? '费用' : '费用'}`, }, upcomingTravel: '即将出行', upcomingTravelSection: { diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 161c7f72ecf8..63d8bfd1478c 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -1,6 +1,7 @@ import type {ValueOf} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; +import flaggedExpensesConfig from './configs/flaggedExpenses'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; import openAndSubmittedReportsByPolicyIDConfig from './configs/openAndSubmittedReportsByPolicyID'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; @@ -27,6 +28,7 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.TODOS]: todosConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, [ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: openAndSubmittedReportsByPolicyIDConfig, + [ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: flaggedExpensesConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts b/src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts new file mode 100644 index 000000000000..d69e39d75686 --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts @@ -0,0 +1,94 @@ +import {getVisibleTransactionViolations} from '@libs/TransactionUtils'; +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FlaggedExpensesDerivedValue} from '@src/types/onyx'; +import type Report from '@src/types/onyx/Report'; +import type TransactionViolations from '@src/types/onyx/TransactionViolation'; + +type FlaggedExpenseEntry = FlaggedExpensesDerivedValue['flaggedExpenses'][number]; + +const EMPTY_VALUE: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; + +/** + * Returns true when this report is an OPEN/OPEN expense report owned by the current user. + * + * `currentUserAccountID` is required. Callers should pass `session?.accountID ?? CONST.DEFAULT_NUMBER_ID` + * so that the ownership check fails closed when the session is not yet populated (no real ownerAccountID is 0). + */ +function isCurrentUserOpenExpenseReport(report: Report | null | undefined, currentUserAccountID: number): boolean { + if (!report) { + return false; + } + if (report.type !== CONST.REPORT.TYPE.EXPENSE) { + return false; + } + if (report.ownerAccountID !== currentUserAccountID) { + return false; + } + return report.stateNum === CONST.REPORT.STATE_NUM.OPEN && report.statusNum === CONST.REPORT.STATUS_NUM.OPEN; +} + +/** Returns true when at least one visible violation should surface in the "Review X expenses" row. */ +function hasReviewableViolation(violations: TransactionViolations | null | undefined): boolean { + if (!violations || violations.length === 0) { + return false; + } + + return violations.some((violation) => { + if (!violation) { + return false; + } + if (violation.showInReview === false) { + return false; + } + if (violation.name === CONST.REPORT_VIOLATIONS.FIELD_REQUIRED) { + return false; + } + if (violation.type === CONST.VIOLATION_TYPES.NOTICE || violation.type === CONST.VIOLATION_TYPES.WARNING) { + return violation.showInReview === true; + } + return true; + }); +} + +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.FLAGGED_EXPENSES, + dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.TRANSACTION, ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.SESSION], + compute: ([allReports, allTransactions, allTransactionViolations, allPolicies, session]) => { + if (!allReports || !allTransactions) { + return EMPTY_VALUE; + } + + const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const currentUserEmail = session?.email ?? ''; + const flaggedExpenses: FlaggedExpenseEntry[] = []; + + for (const transactionKey of Object.keys(allTransactions)) { + const transaction = allTransactions[transactionKey]; + if (!transaction?.transactionID || !transaction.reportID) { + continue; + } + + const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + if (!isCurrentUserOpenExpenseReport(report, currentUserAccountID)) { + continue; + } + + const violations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; + if (!violations || violations.length === 0) { + continue; + } + + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + const visibleViolations = getVisibleTransactionViolations(transaction, violations, currentUserEmail, currentUserAccountID, report, policy); + if (!hasReviewableViolation(visibleViolations)) { + continue; + } + + flaggedExpenses.push({transactionID: transaction.transactionID, reportID: transaction.reportID}); + } + + return {flaggedExpenses}; + }, +}); diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 150f65a6827d..0bb103ce149f 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -140,7 +140,18 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm const backTo = (currentRouteInfo?.params as {backTo?: Route})?.backTo; const screenName = currentRouteInfo?.name; - const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`); + // Besides the central-pane report route (/r/:reportID), the optimistic report can also be focused + // in the RHP transaction-thread carousel (search/view/:reportID) used by the "Review X expenses" + // flow. Matched via route name + reportID param (not a path substring) since the carousel's nested + // backTo can contain other report IDs. Without this, the report is cleared out from under the user + // and ReportNavigateAwayHandler treats it as removed, dismissing the RHP and navigating to the parent. + const isOptimisticReportFocusedInSearchRHP = + screenName === SCREENS.RIGHT_MODAL.SEARCH_REPORT && + !!currentRouteInfo?.params && + typeof currentRouteInfo.params === 'object' && + 'reportID' in currentRouteInfo.params && + currentRouteInfo.params.reportID === reportID; + const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`) || isOptimisticReportFocusedInSearchRHP; // Fix specific case: https://github.com/Expensify/App/pull/77657#issuecomment-3678696730. // When user is editing a money request report (/e/:reportID route) and has diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index 9cdb43c4e220..52494eb0739f 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -18,6 +19,7 @@ import {accountIDSelector} from '@src/selectors/Session'; import todosReportCountsSelector, {EMPTY_TODOS_SINGLE_REPORT_IDS, todosSingleReportIDsSelector} from '@src/selectors/Todos'; import EmptyState from './EmptyState'; import ForYouSkeleton from './ForYouSkeleton'; +import useReviewFlaggedExpenses from './useReviewFlaggedExpenses'; function ForYouSection() { const styles = useThemeStyles(); @@ -33,24 +35,32 @@ function ForYouSection() { const [hasLoadedApp = false] = useOnyx(ONYXKEYS.HAS_LOADED_APP); const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector}); const [singleReportIDs = EMPTY_TODOS_SINGLE_REPORT_IDS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosSingleReportIDsSelector}); + const {count: flaggedExpensesCount, reviewExpenses} = useReviewFlaggedExpenses(); - const icons = useMemoizedLazyExpensifyIcons(['MoneyBag', 'Send', 'ThumbsUp', 'Export']); + const icons = useMemoizedLazyExpensifyIcons(['ReceiptSearch', 'MoneyBag', 'Send', 'ThumbsUp', 'Export']); const submitCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.SUBMIT] ?? 0; const approveCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.APPROVE] ?? 0; const payCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.PAY] ?? 0; const exportCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.EXPORT] ?? 0; - const hasAnyTodos = submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; + const hasAnyTodos = flaggedExpensesCount > 0 || submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; + + const navigateToReport = useCallback( + (reportID: string) => { + if (shouldUseNarrowLayout) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, ROUTES.HOME)); + return; + } + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID, backTo: ROUTES.HOME})); + }, + [shouldUseNarrowLayout], + ); const createNavigationHandler = useCallback( (action: string, queryParams: Record, reportID?: string) => () => { if (reportID) { - if (shouldUseNarrowLayout) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, ROUTES.HOME)); - } else { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID, backTo: ROUTES.HOME})); - } + navigateToReport(reportID); return; } @@ -64,12 +74,22 @@ function ForYouSection() { }), ); }, - [shouldUseNarrowLayout], + [navigateToReport], ); const todoItems = useMemo( () => [ + { + key: 'reviewExpenses', + count: flaggedExpensesCount, + icon: icons.ReceiptSearch, + iconBackgroundColor: colors.tangerine100, + iconFill: colors.tangerine500, + translationKey: 'homePage.forYouSection.reviewExpenses' as const, + handler: reviewExpenses, + buttonProps: {danger: true} as const, + }, { key: 'submit', count: submitCount, @@ -107,21 +127,36 @@ function ForYouSection() { ), }, ].filter((item) => item.count > 0), - [accountID, approveCount, createNavigationHandler, exportCount, icons.Export, icons.MoneyBag, icons.Send, icons.ThumbsUp, payCount, singleReportIDs, submitCount], + [ + accountID, + approveCount, + createNavigationHandler, + reviewExpenses, + exportCount, + flaggedExpensesCount, + icons.Export, + icons.MoneyBag, + icons.ReceiptSearch, + icons.Send, + icons.ThumbsUp, + payCount, + singleReportIDs, + submitCount, + ], ); const renderTodoItems = () => ( - {todoItems.map(({key, count, icon, translationKey, handler}) => ( + {todoItems.map(({key, count, icon, iconBackgroundColor, iconFill, translationKey, handler, buttonProps}) => ( ))} diff --git a/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts new file mode 100644 index 000000000000..3957511ad012 --- /dev/null +++ b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts @@ -0,0 +1,51 @@ +import {useCallback} from 'react'; +import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; +import useOnyx from '@hooks/useOnyx'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {EMPTY_FLAGGED_EXPENSES_REVIEW, flaggedExpensesReviewSelector} from '@src/selectors/Todos'; + +type ReviewFlaggedExpenses = { + /** Number of flagged expenses awaiting review, used to decide whether to render the review row */ + count: number; + + /** Opens the first flagged expense's transaction thread with the full set of flagged expenses as the review carousel */ + reviewExpenses: () => void; +}; + +/** + * Encapsulates the data plumbing for the "Review X expenses" row in the For You section: it reads the + * flagged-expenses review summary plus the first flagged expense's report, report actions, and transaction, + * and exposes a bound handler that navigates to the transaction thread (single-expense RHP + review carousel). + */ +function useReviewFlaggedExpenses(): ReviewFlaggedExpenses { + const [review = EMPTY_FLAGGED_EXPENSES_REVIEW] = useOnyx(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, {selector: flaggedExpensesReviewSelector}); + + // Load the first flagged expense's parent report, its actions, and the transaction itself so the shared + // navigation hook can resolve (or optimistically create) the transaction thread. + const [firstFlaggedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(review.firstReportID)}`); + const [firstFlaggedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(review.firstReportID)}`); + const [firstFlaggedTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(review.firstTransactionID)}`); + + const navigateToTransactionThread = useNavigateToTransactionThread(); + + const reviewExpenses = useCallback(() => { + const {firstTransactionID, firstReportID, transactionIDs} = review; + if (!firstTransactionID || !firstReportID) { + return; + } + navigateToTransactionThread({ + transactionID: firstTransactionID, + reportActions: Object.values(firstFlaggedReportActions ?? {}), + report: firstFlaggedReport, + transaction: firstFlaggedTransaction, + siblingTransactionIDs: transactionIDs, + backTo: ROUTES.HOME, + }); + }, [review, firstFlaggedReportActions, firstFlaggedReport, firstFlaggedTransaction, navigateToTransactionThread]); + + return {count: review.count, reviewExpenses}; +} + +export default useReviewFlaggedExpenses; diff --git a/src/selectors/Todos.ts b/src/selectors/Todos.ts index a1108233c703..b0a628f1ed5a 100644 --- a/src/selectors/Todos.ts +++ b/src/selectors/Todos.ts @@ -1,7 +1,7 @@ import {shallowEqual} from 'fast-equals'; import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {TodosDerivedValue} from '@src/types/onyx'; +import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; const EMPTY_TODOS_SINGLE_REPORT_IDS = Object.freeze({ [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined, @@ -66,5 +66,62 @@ const todosSingleReportIDsSelector = (todos: OnyxEntry) => { return newValue; }; +type FlaggedExpensesReview = { + /** Total number of flagged expenses */ + count: number; + /** Transaction ID of the first flagged expense, used to seed the carousel handoff */ + firstTransactionID: string | undefined; + /** Parent report ID of the first flagged expense */ + firstReportID: string | undefined; + /** Ordered list of every flagged transaction ID, used to drive the prev/next review carousel */ + transactionIDs: string[]; +}; + +const EMPTY_FLAGGED_EXPENSES_REVIEW: FlaggedExpensesReview = Object.freeze({ + count: 0, + firstTransactionID: undefined, + firstReportID: undefined, + transactionIDs: [], +}) as FlaggedExpensesReview; + +// Manual memoization mirrors `todosSingleReportIDsSelector`: ForYouSection's useMemo dependency +// list consumes the returned object as a whole, so we need referential stability across +// equivalent derived snapshots to avoid cascading re-renders on every Onyx churn. +let previousFlaggedExpensesReview: FlaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW; + +const flaggedExpensesReviewSelector = (flaggedExpensesValue: OnyxEntry): FlaggedExpensesReview => { + const flaggedExpenses = flaggedExpensesValue?.flaggedExpenses; + if (!flaggedExpenses || flaggedExpenses.length === 0) { + previousFlaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW; + return EMPTY_FLAGGED_EXPENSES_REVIEW; + } + + const first = flaggedExpenses.at(0); + const transactionIDs = flaggedExpenses.map((flaggedExpense) => flaggedExpense.transactionID); + const newValue: FlaggedExpensesReview = { + count: flaggedExpenses.length, + firstTransactionID: first?.transactionID, + firstReportID: first?.reportID, + transactionIDs, + }; + + // shallowEqual would always report a difference because `transactionIDs` is a fresh array each call, + // so we compare the scalar fields shallowly and the IDs element-by-element to preserve referential stability. + const previousTransactionIDs = previousFlaggedExpensesReview.transactionIDs; + const areTransactionIDsEqual = + previousTransactionIDs.length === transactionIDs.length && previousTransactionIDs.every((transactionID, index) => transactionID === transactionIDs.at(index)); + if ( + previousFlaggedExpensesReview.count === newValue.count && + previousFlaggedExpensesReview.firstTransactionID === newValue.firstTransactionID && + previousFlaggedExpensesReview.firstReportID === newValue.firstReportID && + areTransactionIDsEqual + ) { + return previousFlaggedExpensesReview; + } + + previousFlaggedExpensesReview = newValue; + return newValue; +}; + export default todosReportCountsSelector; -export {todosSingleReportIDsSelector, EMPTY_TODOS_SINGLE_REPORT_IDS}; +export {EMPTY_FLAGGED_EXPENSES_REVIEW, EMPTY_TODOS_SINGLE_REPORT_IDS, flaggedExpensesReviewSelector, todosSingleReportIDsSelector}; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index bbcecb4f4d6f..46128996dce1 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -266,6 +266,23 @@ type TodosDerivedValue = { transactionsByReportID: Record; }; +/** + * The derived value for flagged expenses. + * + * Aggregates transactions on the current user's `OPEN`/`OPEN` expense reports that have + * at least one transaction-level violation (excluding `showInReview === false` entries and + * `REPORT_VIOLATIONS.FIELD_REQUIRED` entries that may slip into the collection). + */ +type FlaggedExpensesDerivedValue = { + /** Ordered list of flagged transactions with their parent report IDs */ + flaggedExpenses: Array<{ + /** ID of the flagged transaction */ + transactionID: string; + /** ID of the parent expense report */ + reportID: string; + }>; +}; + /** * The derived value for sorted report actions, last report actions, and cached transaction thread report IDs. */ @@ -297,6 +314,7 @@ export type { CardFeedErrorsDerivedValue, TodosDerivedValue, TodoMetadata, + FlaggedExpensesDerivedValue, CardFeedErrorsObject, CardFeedErrorState, CardFeedErrors, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 73d114a88216..32c74c6c63d9 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -49,6 +49,7 @@ import type CustomStatusDraft from './CustomStatusDraft'; import type DeferredAgentWorkflowSave from './DeferredAgentWorkflowSave'; import type { CardFeedErrorsDerivedValue, + FlaggedExpensesDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, OpenAndSubmittedReportsByPolicyIDDerivedValue, OutstandingReportsByPolicyIDDerivedValue, @@ -405,6 +406,7 @@ export type { CardFeedErrorsDerivedValue, TodosDerivedValue, TodoMetadata, + FlaggedExpensesDerivedValue, ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, diff --git a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts index f879c4dcb8cb..6bf71df0c476 100644 --- a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts +++ b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts @@ -917,6 +917,71 @@ describe('replaceOptimisticReportWithActualReport', () => { subscription.remove(); }); + it('should swap to preexisting thread when focused on the search RHP transaction-thread carousel', async () => { + // Given an optimistic transaction thread under a multi-transaction IOU report opened in the + // "Review X expenses" carousel, i.e. the focused route is the SEARCH_REPORT RHP (search/view/:reportID) + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + + mockIsReady.mockReturnValue(true); + // The carousel route is search/view/, NOT /r/ + mockGetActiveRoute.mockReturnValue(`/search/view/${optimisticReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {reportID: optimisticReportID}}); + + const reportActionID1 = '1'; + const reportActionID2 = '2'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 2, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID1]: { + reportActionID: reportActionID1, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans1'}, + }, + [reportActionID2]: { + reportActionID: reportActionID2, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans2'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID1, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${optimisticReportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called and the callback is executed + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then the switch event is emitted (the focused optimistic report is recognized on the RHP route) + expect(capturedEventData).toBeDefined(); + + capturedEventData?.callback(); + await waitForBatchedUpdates(); + + // And the carousel is swapped to the preexisting thread via setParams (staying in the RHP), + // instead of being cleared out and bounced to the parent report + expect(mockSetParams).toHaveBeenCalledWith({reportID: preexistingReportID.toString()}); + + subscription.remove(); + }); + it('should update backTo route when optimistic report is in background on expense report screen', async () => { // Given an optimistic report in the background while user is on expense report screen with backTo pointing to it const reportID = '1'; diff --git a/tests/ui/ForYouSectionTest.tsx b/tests/ui/ForYouSectionTest.tsx index ebdcf1ce65ca..14877cc43c75 100644 --- a/tests/ui/ForYouSectionTest.tsx +++ b/tests/ui/ForYouSectionTest.tsx @@ -4,9 +4,10 @@ import Onyx from 'react-native-onyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import Navigation from '@libs/Navigation/Navigation'; import ForYouSection from '@pages/home/ForYouSection'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {TodosDerivedValue} from '@src/types/onyx'; +import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@libs/Navigation/Navigation', () => ({ @@ -17,6 +18,9 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +const mockNavigateToTransactionThread = jest.fn(); +jest.mock('@hooks/useNavigateToTransactionThread', () => jest.fn(() => mockNavigateToTransactionThread)); + jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn((key: string, params?: Record) => { @@ -44,12 +48,15 @@ jest.mock('@hooks/useThemeStyles', () => jest.mock('@hooks/useTheme', () => jest.fn(() => ({}))); +const RECEIPT_SEARCH_ASSET = {testID: 'receipt-search-icon'}; + jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: jest.fn(() => ({ MoneyBag: null, Send: null, ThumbsUp: null, Export: null, + ReceiptSearch: RECEIPT_SEARCH_ASSET, })), useMemoizedLazyIllustrations: jest.fn(() => ({ ThumbsUpStars: null, @@ -75,6 +82,8 @@ const BASE_TODOS: TodosDerivedValue = { transactionsByReportID: {}, }; +const EMPTY_FLAGGED_EXPENSES: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; + function renderForYouSection() { return render(); } @@ -125,6 +134,7 @@ describe('ForYouSection', () => { it('renders EmptyState when there are no todos', async () => { await act(async () => { await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, EMPTY_FLAGGED_EXPENSES); }); await waitForBatchedUpdatesWithAct(); @@ -135,6 +145,177 @@ describe('ForYouSection', () => { }); }); + describe('review row', () => { + it('is not rendered when there are no flagged expenses', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + ...BASE_TODOS, + reportsToSubmit: [{reportID: '1'} as TodosDerivedValue['reportsToSubmit'][number]], + }); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, EMPTY_FLAGGED_EXPENSES); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(/homePage\.forYouSection\.reviewExpenses/)).not.toBeOnTheScreen(); + }); + + it('renders with the count-1 string when exactly one expense is flagged', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('homePage.forYouSection.reviewExpenses:{"count":1}')).toBeOnTheScreen(); + }); + + it('renders with the count-N string when multiple expenses are flagged', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [ + {transactionID: 't1', reportID: 'r1'}, + {transactionID: 't2', reportID: 'r2'}, + {transactionID: 't3', reportID: 'r3'}, + ], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('homePage.forYouSection.reviewExpenses:{"count":3}')).toBeOnTheScreen(); + }); + + it('renders the review row above submit/approve/pay/export rows', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + ...BASE_TODOS, + reportsToSubmit: [{reportID: 's1'} as TodosDerivedValue['reportsToSubmit'][number]], + reportsToApprove: [{reportID: 'a1'} as TodosDerivedValue['reportsToApprove'][number]], + reportsToPay: [{reportID: 'p1'} as TodosDerivedValue['reportsToPay'][number]], + reportsToExport: [{reportID: 'e1'} as TodosDerivedValue['reportsToExport'][number]], + }); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + const reviewTitle = screen.getByText('homePage.forYouSection.reviewExpenses:{"count":1}'); + const submitTitle = screen.getByText('homePage.forYouSection.submit:{"count":1}'); + const approveTitle = screen.getByText('homePage.forYouSection.approve:{"count":1}'); + const payTitle = screen.getByText('homePage.forYouSection.pay:{"count":1}'); + const exportTitle = screen.getByText('homePage.forYouSection.export:{"count":1}'); + + // jest-native renderer assigns sequential _nativeTag-like positions; we rely on + // the order returned by getAllByText to verify rendering order. + const allTitles = screen.getAllByText(/homePage\.forYouSection\.(reviewExpenses|submit|approve|pay|export):/); + const getChildren = (node: {props: {children?: unknown}}) => node.props.children; + const titleOrder = allTitles.map(getChildren); + + expect(titleOrder.at(0)).toBe(getChildren(reviewTitle)); + expect(titleOrder).toEqual([getChildren(reviewTitle), getChildren(submitTitle), getChildren(approveTitle), getChildren(payTitle), getChildren(exportTitle)]); + }); + + it('exposes a Begin CTA and uses the ReceiptSearch icon asset', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + const {UNSAFE_root: unsafeRoot} = renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Begin')).toBeOnTheScreen(); + + // The ReceiptSearch asset should be passed as the `icon` prop on at least one BaseWidgetItem. + // We look for any rendered element whose `icon` prop is the RECEIPT_SEARCH_ASSET reference. + const matchingNodes = unsafeRoot.findAll((node) => node.props && (node.props as {icon?: unknown}).icon === RECEIPT_SEARCH_ASSET); + expect(matchingNodes.length).toBeGreaterThan(0); + }); + }); + + describe('review row navigation', () => { + it('delegates to useNavigateToTransactionThread with the first flagged expense and all sibling transaction IDs', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [ + {transactionID: 't1', reportID: 'r1'}, + {transactionID: 't2', reportID: 'r2'}, + ], + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}r1`, {reportID: 'r1'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}r1`, { + action1: { + reportActionID: 'action1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: 'thread-r1', + message: {IOUTransactionID: 't1'}, + }, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}t1`, {transactionID: 't1', reportID: 'r1'}); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + pressFirstBeginButton(); + + expect(mockNavigateToTransactionThread).toHaveBeenCalledTimes(1); + expect(mockNavigateToTransactionThread).toHaveBeenCalledWith( + expect.objectContaining({ + transactionID: 't1', + report: expect.objectContaining({reportID: 'r1'}), + transaction: expect.objectContaining({transactionID: 't1'}), + reportActions: expect.arrayContaining([expect.objectContaining({reportActionID: 'action1', childReportID: 'thread-r1'})]), + siblingTransactionIDs: ['t1', 't2'], + backTo: ROUTES.HOME, + }), + ); + // The standard report routes should not be used for the review row anymore. + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not call the hook when there is no flagged transaction or report', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + // count > 0 keeps the row rendered, but the first transaction/report are missing + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: '', reportID: ''}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + const beginButton = screen.queryByText('Begin'); + if (beginButton) { + fireEvent.press(beginButton); + } + + expect(mockNavigateToTransactionThread).not.toHaveBeenCalled(); + }); + }); + describe('navigation with multiple reports (search route)', () => { it('navigates to SEARCH_ROOT when submit has multiple reports', async () => { await act(async () => { diff --git a/tests/unit/OnyxDerived/flaggedExpensesTest.ts b/tests/unit/OnyxDerived/flaggedExpensesTest.ts new file mode 100644 index 000000000000..12628862c3c0 --- /dev/null +++ b/tests/unit/OnyxDerived/flaggedExpensesTest.ts @@ -0,0 +1,300 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import flaggedExpensesConfig from '@libs/actions/OnyxDerived/configs/flaggedExpenses'; +import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Session, Transaction, TransactionViolations} from '@src/types/onyx'; +import {createMockReport} from '../../utils/ReportTestUtils'; + +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'user@example.com'; +const OTHER_USER_ACCOUNT_ID = 2; +const POLICY_ID_1 = 'policy1'; +const POLICY_ID_2 = 'policy2'; + +function createExpenseReport(reportID: string, overrides: Partial = {}): Report { + return createMockReport({ + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + policyID: POLICY_ID_1, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + ...overrides, + }); +} + +function createTransaction(transactionID: string, reportID: string, overrides: Partial = {}): Transaction { + return { + transactionID, + reportID, + amount: 100, + currency: 'USD', + created: '2024-01-01', + merchant: 'Test Merchant', + ...overrides, + } as Transaction; +} + +function buildReports(...reports: Report[]): OnyxCollection { + const result: OnyxCollection = {}; + for (const report of reports) { + result[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; + } + return result; +} + +function buildTransactions(...transactions: Transaction[]): OnyxCollection { + const result: OnyxCollection = {}; + for (const transaction of transactions) { + result[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + } + return result; +} + +function buildViolations(violationsByTransactionID: Record): OnyxCollection { + const result: OnyxCollection = {}; + for (const [transactionID, violations] of Object.entries(violationsByTransactionID)) { + result[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] = violations; + } + return result; +} + +function createSession(accountID: number = CURRENT_USER_ACCOUNT_ID, email: string = CURRENT_USER_EMAIL): Session { + return {accountID, email} as Session; +} + +const {compute} = flaggedExpensesConfig; +const emptyContext = {} as DerivedValueContext< + typeof ONYXKEYS.DERIVED.FLAGGED_EXPENSES, + [typeof ONYXKEYS.COLLECTION.REPORT, typeof ONYXKEYS.COLLECTION.TRANSACTION, typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, typeof ONYXKEYS.COLLECTION.POLICY, typeof ONYXKEYS.SESSION] +>; + +describe('flaggedExpenses derived value', () => { + describe('presence and count', () => { + it('reports zero flagged expenses and an empty list when no transactions have violations', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const result = compute([buildReports(report), buildTransactions(transaction), {}, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + + it('counts two flagged transactions on OPEN/OPEN expense reports across two workspaces', () => { + const report1 = createExpenseReport('r1', {policyID: POLICY_ID_1}); + const report2 = createExpenseReport('r2', {policyID: POLICY_ID_2}); + const transaction1 = createTransaction('t1', 'r1'); + const transaction2 = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(report1, report2), buildTransactions(transaction1, transaction2), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(2); + const ids = result.flaggedExpenses.map((entry) => entry.transactionID).sort(); + expect(ids).toEqual(['t1', 't2']); + const reportIDs = result.flaggedExpenses.map((entry) => entry.reportID).sort(); + expect(reportIDs).toEqual(['r1', 'r2']); + }); + + it('counts a single transaction with multiple violations as 1 expense', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [ + {type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}, + {type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_TAG}, + ], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('counts every non-report-field violation type that OpenApp returns', () => { + const report = createExpenseReport('r1'); + const transactions = [createTransaction('tA', 'r1'), createTransaction('tB', 'r1'), createTransaction('tC', 'r1')]; + const violations = buildViolations({ + tA: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.OVER_CATEGORY_LIMIT}], + tB: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_TAG}], + tC: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_COMMENT}], + }); + + const result = compute([buildReports(report), buildTransactions(...transactions), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(3); + expect(result.flaggedExpenses.map((entry) => entry.transactionID).sort()).toEqual(['tA', 'tB', 'tC']); + }); + + it('excludes violations with showInReview === false', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY, showInReview: false}], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + + it('excludes warning/notice violations unless showInReview is explicitly true', () => { + const report = createExpenseReport('r1'); + const transactions = [createTransaction('tWarn', 'r1'), createTransaction('tNotice', 'r1'), createTransaction('tShown', 'r1')]; + const violations = buildViolations({ + tWarn: [{type: CONST.VIOLATION_TYPES.WARNING, name: CONST.VIOLATIONS.RTER}], + tNotice: [{type: CONST.VIOLATION_TYPES.NOTICE, name: CONST.VIOLATIONS.RECEIPT_REQUIRED}], + tShown: [{type: CONST.VIOLATION_TYPES.NOTICE, name: CONST.VIOLATIONS.RECEIPT_REQUIRED, showInReview: true}], + }); + + const result = compute([buildReports(report), buildTransactions(...transactions), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([{transactionID: 'tShown', reportID: 'r1'}]); + }); + }); + + describe('Draft-report scope and resolution', () => { + it('excludes flagged transactions on submitted expense reports', () => { + const draftReport = createExpenseReport('r1'); + const submittedReport = createExpenseReport('r2', { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + const draftTransaction = createTransaction('t1', 'r1'); + const submittedTransaction = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(draftReport, submittedReport), buildTransactions(draftTransaction, submittedTransaction), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('excludes flagged transactions whose parent report is not an expense report', () => { + const expenseReport = createExpenseReport('r1'); + const chatReport = createMockReport({ + reportID: 'r2', + type: CONST.REPORT.TYPE.CHAT, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + const t1 = createTransaction('t1', 'r1'); + const t2 = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(expenseReport, chatReport), buildTransactions(t1, t2), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('does not count report-field violations as flagged expenses', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.REPORT_VIOLATIONS.FIELD_REQUIRED} as TransactionViolations[number]], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + + it('drops a transaction from the list when its last violation is cleared', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const flaggedViolations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const flaggedResult = compute([buildReports(report), buildTransactions(transaction), flaggedViolations, {}, createSession()], emptyContext); + expect(flaggedResult.flaggedExpenses).toHaveLength(1); + + const clearedViolations = buildViolations({t1: []}); + const clearedResult = compute([buildReports(report), buildTransactions(transaction), clearedViolations, {}, createSession()], emptyContext); + expect(clearedResult.flaggedExpenses).toEqual([]); + }); + + it('restores a previously cleaned transaction once it is re-flagged', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + + const clearedResult = compute([buildReports(report), buildTransactions(transaction), buildViolations({t1: []}), {}, createSession()], emptyContext); + expect(clearedResult.flaggedExpenses).toEqual([]); + + const reFlaggedResult = compute( + [ + buildReports(report), + buildTransactions(transaction), + buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }), + {}, + createSession(), + ], + emptyContext, + ); + expect(reFlaggedResult.flaggedExpenses).toHaveLength(1); + expect(reFlaggedResult.flaggedExpenses.at(0)?.transactionID).toBe('t1'); + }); + + it('excludes flagged transactions on expense reports the current user does not own', () => { + const ownReport = createExpenseReport('r1'); + const foreignReport = createExpenseReport('r2', {ownerAccountID: OTHER_USER_ACCOUNT_ID}); + const ownTransaction = createTransaction('t1', 'r1'); + const foreignTransaction = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(ownReport, foreignReport), buildTransactions(ownTransaction, foreignTransaction), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('excludes all flagged transactions when the session has no accountID', () => { + const report = createExpenseReport('r1', {ownerAccountID: OTHER_USER_ACCOUNT_ID}); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, {}, {} as Session], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + + it('excludes transactions when the current user dismissed the only reviewable violation', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1', { + comment: { + dismissedViolations: { + [CONST.VIOLATIONS.MISSING_CATEGORY]: { + [CURRENT_USER_EMAIL]: '2024-01-01', + }, + }, + }, + }); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + }); +}); diff --git a/tests/unit/selectors/TodosTest.ts b/tests/unit/selectors/TodosTest.ts index 25e57801cd2a..9d90b76cb698 100644 --- a/tests/unit/selectors/TodosTest.ts +++ b/tests/unit/selectors/TodosTest.ts @@ -1,6 +1,6 @@ -import todosReportCountsSelector, {todosSingleReportIDsSelector} from '@selectors/Todos'; +import todosReportCountsSelector, {flaggedExpensesReviewSelector, todosSingleReportIDsSelector} from '@selectors/Todos'; import CONST from '@src/CONST'; -import type {TodosDerivedValue} from '@src/types/onyx'; +import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; describe('todosReportCountsSelector', () => { it('returns undefined when todos is undefined', () => { @@ -212,3 +212,69 @@ describe('todosSingleReportIDsSelector', () => { expect(second[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe('21'); }); }); + +describe('flaggedExpensesReviewSelector', () => { + it('returns count 0 and undefined identifiers when input is undefined', () => { + const result = flaggedExpensesReviewSelector(undefined); + + expect(result.count).toBe(0); + expect(result.firstTransactionID).toBeUndefined(); + expect(result.firstReportID).toBeUndefined(); + }); + + it('returns count 0 and undefined identifiers when there are no flagged expenses', () => { + const value: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; + + const result = flaggedExpensesReviewSelector(value); + + expect(result.count).toBe(0); + expect(result.firstTransactionID).toBeUndefined(); + expect(result.firstReportID).toBeUndefined(); + }); + + it('returns aggregated count and stable first identifiers for multiple flagged expenses', () => { + const value: FlaggedExpensesDerivedValue = { + flaggedExpenses: [ + {transactionID: 't1', reportID: 'r1'}, + {transactionID: 't2', reportID: 'r2'}, + {transactionID: 't3', reportID: 'r3'}, + ], + }; + + const result = flaggedExpensesReviewSelector(value); + + expect(result.count).toBe(3); + expect(result.firstTransactionID).toBe('t1'); + expect(result.firstReportID).toBe('r1'); + }); + + it('returns the same object reference when called twice with shallowly equal values', () => { + const value: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tA', reportID: 'rA'}], + }; + const first = flaggedExpensesReviewSelector(value); + + const clone: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tA', reportID: 'rA'}], + }; + const second = flaggedExpensesReviewSelector(clone); + + expect(second).toBe(first); + }); + + it('returns a new object reference when the first flagged expense changes', () => { + const value: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tA', reportID: 'rA'}], + }; + const first = flaggedExpensesReviewSelector(value); + + const changed: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tB', reportID: 'rB'}], + }; + const second = flaggedExpensesReviewSelector(changed); + + expect(second).not.toBe(first); + expect(second.firstTransactionID).toBe('tB'); + expect(second.firstReportID).toBe('rB'); + }); +});