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');
+ });
+});