From edc49569026c0791984e8f11139e128def25eb2f Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 24 Jun 2026 15:11:16 +0200 Subject: [PATCH 01/16] Decouple NewReportWorkspaceSelection from TODOS derived value --- ...DynamicNewReportWorkspaceSelectionPage.tsx | 20 ++++++++++++------- .../NewReportWorkspaceSelectionPageTest.tsx | 20 ++++--------------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index 400cc67be56f..bbcd7ffe4e04 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -1,6 +1,6 @@ import {policyIDsWithEmptyReportsSelector} from '@selectors/Report'; import {accountIDSelector} from '@selectors/Session'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -39,6 +39,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type WorkspaceListItem = { @@ -83,13 +84,18 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [todos] = useOnyx(ONYXKEYS.DERIVED.TODOS); - const transactionsByReportID = todos?.transactionsByReportID; + const transactionsByReportID: Record = {}; + for (const transaction of Object.values(allTransactions ?? {})) { + if (!transaction?.reportID) { + continue; + } + if (!transactionsByReportID[transaction.reportID]) { + transactionsByReportID[transaction.reportID] = []; + } + transactionsByReportID[transaction.reportID].push(transaction); + } - const policiesWithEmptyReportsForAccountSelector = useMemo( - () => policyIDsWithEmptyReportsSelector(accountID, transactionsByReportID ?? {}, !!hasDismissedEmptyReportsConfirmation), - [accountID, transactionsByReportID, hasDismissedEmptyReportsConfirmation], - ); + const policiesWithEmptyReportsForAccountSelector = policyIDsWithEmptyReportsSelector(accountID, transactionsByReportID, !!hasDismissedEmptyReportsConfirmation); const [policiesWithEmptyReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policiesWithEmptyReportsForAccountSelector}); const navigateToNewReport = (optimisticReportID: string) => { diff --git a/tests/ui/NewReportWorkspaceSelectionPageTest.tsx b/tests/ui/NewReportWorkspaceSelectionPageTest.tsx index 9fbc037b26a1..b563db8c3659 100644 --- a/tests/ui/NewReportWorkspaceSelectionPageTest.tsx +++ b/tests/ui/NewReportWorkspaceSelectionPageTest.tsx @@ -13,7 +13,7 @@ import DynamicNewReportWorkspaceSelectionPage from '@pages/DynamicNewReportWorks import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -import type {Policy, Report, TodosDerivedValue, Transaction} from '@src/types/onyx'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@libs/actions/Report', () => ({ @@ -45,14 +45,6 @@ const POLICY_ID = 'policy-1'; const POLICY_NAME = 'Test Workspace'; const REPORT_ID = 'report-1'; -const BASE_TODOS: TodosDerivedValue = { - reportsToSubmit: [], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: {}, -}; - const Stack = createPlatformStackNavigator(); function renderPage() { @@ -116,10 +108,9 @@ describe('NewReportWorkspaceSelectionPage', () => { jest.clearAllMocks(); }); - it('opens the empty-report confirmation when TODOS has no transactions for the user report', async () => { + it('opens the empty-report confirmation when there are no transactions for the user report', async () => { await act(async () => { await seedBaseOnyx(); - await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); }); await waitForBatchedUpdatesWithAct(); @@ -133,7 +124,7 @@ describe('NewReportWorkspaceSelectionPage', () => { expect(mockCreateNewReport).not.toHaveBeenCalled(); }); - it('creates the report directly when TODOS has a live transaction for the user report', async () => { + it('creates the report directly when there is a live transaction for the user report', async () => { const transaction: Partial = { transactionID: 'txn-1', reportID: REPORT_ID, @@ -141,10 +132,7 @@ describe('NewReportWorkspaceSelectionPage', () => { }; await act(async () => { await seedBaseOnyx(); - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { - ...BASE_TODOS, - transactionsByReportID: {[REPORT_ID]: [transaction as Transaction]}, - }); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction as Transaction); }); await waitForBatchedUpdatesWithAct(); From 94ca90ed7d2800bff91d8b1b9363041a96aab17c Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 24 Jun 2026 15:17:56 +0200 Subject: [PATCH 02/16] Extract createTodosReportsAndTransactions into shared TodosUtils --- src/libs/TodosUtils.ts | 95 +++++++++++++++++++ src/libs/actions/OnyxDerived/configs/todos.ts | 87 +---------------- 2 files changed, 96 insertions(+), 86 deletions(-) create mode 100644 src/libs/TodosUtils.ts diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts new file mode 100644 index 000000000000..dff9c6d7c756 --- /dev/null +++ b/src/libs/TodosUtils.ts @@ -0,0 +1,95 @@ +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {BankAccountList, Policy, Report, ReportActions, ReportMetadata, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import {isApproveAction, isExportAction, isPrimaryPayAction, isSubmitAction} from './ReportPrimaryActionUtils'; +import {hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions} from './ReportUtils'; + +type CreateTodosReportsAndTransactionsParams = { + allReports: OnyxCollection; + allTransactions: OnyxCollection; + allPolicies: OnyxCollection; + allReportNameValuePairs: OnyxCollection; + allReportActions: OnyxCollection; + allReportMetadata: OnyxCollection; + bankAccountList: OnyxEntry; + currentUserAccountID: number; + login: string; +}; + +/** + * Classifies every expense report into the four to-do buckets (submit/approve/pay/export) and indexes + * transactions by report ID. Shared by the TODOS derived value and the on-demand to-do hooks. + */ +function createTodosReportsAndTransactions({ + allReports, + allTransactions, + allPolicies, + allReportNameValuePairs, + allReportActions, + allReportMetadata, + bankAccountList, + currentUserAccountID, + login, +}: CreateTodosReportsAndTransactionsParams) { + const reportsToSubmit: Report[] = []; + const reportsToApprove: Report[] = []; + const reportsToPay: Report[] = []; + const reportsToExport: Report[] = []; + const transactionsByReportID: Record = {}; + + const reports = Object.values(allReports ?? {}); + if (reports.length === 0) { + return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport, transactionsByReportID}; + } + + if (allTransactions) { + for (const transaction of Object.values(allTransactions)) { + if (!transaction?.reportID) { + continue; + } + (transactionsByReportID[transaction.reportID] ??= []).push(transaction); + } + } + + for (const report of reports) { + if (!report?.reportID || report.type !== CONST.REPORT.TYPE.EXPENSE) { + continue; + } + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + const reportNameValuePair = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`]; + const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); + const reportTransactions = transactionsByReportID[report.reportID] ?? []; + const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; + const allExpensesHeld = hasOnlyHeldExpenses(reportTransactions); + if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld) { + reportsToSubmit.push(report); + } + if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld) { + reportsToApprove.push(report); + } + if ( + isPrimaryPayAction({ + report, + reportTransactions, + currentUserAccountID, + currentUserLogin: login, + bankAccountList, + policy, + reportNameValuePairs: reportNameValuePair, + }) && + !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) && + !allExpensesHeld + ) { + reportsToPay.push(report); + } + if (isExportAction(report, login, policy, reportActions) && policy?.exporter === login) { + reportsToExport.push(report); + } + } + + return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport, transactionsByReportID}; +} + +export default createTodosReportsAndTransactions; +export type {CreateTodosReportsAndTransactionsParams}; diff --git a/src/libs/actions/OnyxDerived/configs/todos.ts b/src/libs/actions/OnyxDerived/configs/todos.ts index edc9f0a61d9a..121de1ccb206 100644 --- a/src/libs/actions/OnyxDerived/configs/todos.ts +++ b/src/libs/actions/OnyxDerived/configs/todos.ts @@ -1,92 +1,7 @@ -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {isApproveAction, isExportAction, isPrimaryPayAction, isSubmitAction} from '@libs/ReportPrimaryActionUtils'; -import {hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; +import createTodosReportsAndTransactions from '@libs/TodosUtils'; import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BankAccountList, Policy, Report, ReportActions, ReportMetadata, ReportNameValuePairs, Transaction} from '@src/types/onyx'; - -type CreateTodosReportsAndTransactionsParams = { - allReports: OnyxCollection; - allTransactions: OnyxCollection; - allPolicies: OnyxCollection; - allReportNameValuePairs: OnyxCollection; - allReportActions: OnyxCollection; - allReportMetadata: OnyxCollection; - bankAccountList: OnyxEntry; - currentUserAccountID: number; - login: string; -}; - -const createTodosReportsAndTransactions = ({ - allReports, - allTransactions, - allPolicies, - allReportNameValuePairs, - allReportActions, - allReportMetadata, - bankAccountList, - currentUserAccountID, - login, -}: CreateTodosReportsAndTransactionsParams) => { - const reportsToSubmit: Report[] = []; - const reportsToApprove: Report[] = []; - const reportsToPay: Report[] = []; - const reportsToExport: Report[] = []; - const transactionsByReportID: Record = {}; - - const reports = Object.values(allReports ?? {}); - if (reports.length === 0) { - return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport, transactionsByReportID}; - } - - if (allTransactions) { - for (const transaction of Object.values(allTransactions)) { - if (!transaction?.reportID) { - continue; - } - (transactionsByReportID[transaction.reportID] ??= []).push(transaction); - } - } - - for (const report of reports) { - if (!report?.reportID || report.type !== CONST.REPORT.TYPE.EXPENSE) { - continue; - } - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - const reportNameValuePair = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`]; - const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); - const reportTransactions = transactionsByReportID[report.reportID] ?? []; - const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; - const allExpensesHeld = hasOnlyHeldExpenses(reportTransactions); - if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld) { - reportsToSubmit.push(report); - } - if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld) { - reportsToApprove.push(report); - } - if ( - isPrimaryPayAction({ - report, - reportTransactions, - currentUserAccountID, - currentUserLogin: login, - bankAccountList, - policy, - reportNameValuePairs: reportNameValuePair, - }) && - !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) && - !allExpensesHeld - ) { - reportsToPay.push(report); - } - if (isExportAction(report, login, policy, reportActions) && policy?.exporter === login) { - reportsToExport.push(report); - } - } - - return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport, transactionsByReportID}; -}; export default createOnyxDerivedValueConfig({ key: ONYXKEYS.DERIVED.TODOS, From 7aa0ee6b1de81f5d09d8abfd7f15f3289244aff2 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 24 Jun 2026 15:25:46 +0200 Subject: [PATCH 03/16] Add useTodoCounts hook and migrate count consumers off TODOS derived value --- src/hooks/useTodoCounts.ts | 71 +++++++ src/pages/Search/SearchTypeMenuNarrow.tsx | 4 +- src/pages/Search/SearchTypeMenuWide.tsx | 8 +- src/pages/home/ForYouSection/index.tsx | 16 +- src/selectors/Todos.ts | 75 +------- tests/ui/ForYouSectionTest.tsx | 58 ++++-- tests/unit/selectors/TodosTest.ts | 216 +--------------------- 7 files changed, 132 insertions(+), 316 deletions(-) create mode 100644 src/hooks/useTodoCounts.ts diff --git a/src/hooks/useTodoCounts.ts b/src/hooks/useTodoCounts.ts new file mode 100644 index 000000000000..9c119b02fd9d --- /dev/null +++ b/src/hooks/useTodoCounts.ts @@ -0,0 +1,71 @@ +// We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live to-do data +// eslint-disable-next-line no-restricted-imports +import {useOnyx} from 'react-native-onyx'; +import createTodosReportsAndTransactions from '@libs/TodosUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type TodoCounts = { + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: number; + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: number; + [CONST.SEARCH.SEARCH_KEYS.PAY]: number; + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: number; +}; + +type TodoSingleReportIDs = { + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: string | undefined; + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: string | undefined; + [CONST.SEARCH.SEARCH_KEYS.PAY]: string | undefined; + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: string | undefined; +}; + +/** + * Computes live to-do report counts and, for each bucket that contains exactly one report, that report's ID. + * Runs the to-do classification on demand from live Onyx data, only while a consumer is mounted, replacing the + * always-on TODOS derived value for count consumers. + */ +function useTodoCounts(): {counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} { + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + + const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const login = personalDetailsList?.[userAccountID]?.login ?? session?.email ?? ''; + + const {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport} = createTodosReportsAndTransactions({ + allReports, + allTransactions, + allPolicies, + allReportNameValuePairs, + allReportActions, + allReportMetadata, + bankAccountList, + currentUserAccountID: userAccountID, + login, + }); + + const counts: TodoCounts = { + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: reportsToSubmit.length, + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: reportsToApprove.length, + [CONST.SEARCH.SEARCH_KEYS.PAY]: reportsToPay.length, + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: reportsToExport.length, + }; + + const singleReportIDs: TodoSingleReportIDs = { + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: reportsToSubmit.length === 1 ? reportsToSubmit.at(0)?.reportID : undefined, + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: reportsToApprove.length === 1 ? reportsToApprove.at(0)?.reportID : undefined, + [CONST.SEARCH.SEARCH_KEYS.PAY]: reportsToPay.length === 1 ? reportsToPay.at(0)?.reportID : undefined, + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: reportsToExport.length === 1 ? reportsToExport.at(0)?.reportID : undefined, + }; + + return {counts, singleReportIDs}; +} + +export default useTodoCounts; +export type {TodoCounts, TodoSingleReportIDs}; diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index cefced738a69..dcd5de1d28e8 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -22,6 +22,7 @@ import useReportAttributes from '@hooks/useReportAttributes'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import useShareSavedSearch, {MENU_CLOSE_DELAY_MS} from '@hooks/useShareSavedSearch'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTodoCounts from '@hooks/useTodoCounts'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; @@ -29,7 +30,6 @@ import {getItemBadgeText, getOverflowMenu} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {accountIDSelector} from '@src/selectors/Session'; -import todosReportCountsSelector from '@src/selectors/Todos'; import useSavedSearchTitles from './hooks/useSavedSearchTitles'; type SearchTypeMenuNarrowProps = { @@ -92,7 +92,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [workspaceCardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); - const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector}); + const {counts: reportCounts} = useTodoCounts(); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index 4d004ae6a485..49e38db51844 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -15,14 +15,14 @@ import useOnyx from '@hooks/useOnyx'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTodoCounts from '@hooks/useTodoCounts'; +import type {TodoCounts} from '@hooks/useTodoCounts'; import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {getItemBadgeText, getSectionBadgeText} from '@libs/SearchUIUtils'; import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import todosReportCountsSelector from '@src/selectors/Todos'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import SavedSearchList from './SavedSearchList'; import SearchTypeMenuAccordion from './SearchTypeMenuAccordion'; @@ -38,7 +38,7 @@ type SectionParams = { hash: number | undefined; activeItemIndex: number; sectionStartIndex: number; - reportCounts: NonNullable>; + reportCounts: TodoCounts; areAllSectionsExpanded: boolean; onItemPress: (query: string) => void; onCollapsed: (isCollapsed: boolean) => void; @@ -129,7 +129,7 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { const {typeMenuSections, activeItemIndex} = useSearchTypeMenuSections({hash, similarSearchHash, sortBy, sortOrder, type}); const {isVisuallyCollapsed} = useSearchSidebarCollapse(); const [isSearchDataLoaded, isSearchDataLoadedResult] = useOnyx(ONYXKEYS.IS_SEARCH_PAGE_DATA_LOADED); - const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector}); + const {counts: reportCounts} = useTodoCounts(); const route = useRoute(); const scrollViewRef = useRef(null); diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index 52494eb0739f..7c3f819a6064 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -8,6 +8,7 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTodoCounts from '@hooks/useTodoCounts'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -16,7 +17,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; 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'; @@ -33,16 +33,15 @@ function ForYouSection() { // Gating the skeleton on it prevents the section from flashing skeleton on every foreground/reconnect // when IS_LOADING_REPORT_DATA is optimistically set to true by ReconnectApp. 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 {counts: reportCounts, singleReportIDs} = useTodoCounts(); const {count: flaggedExpensesCount, reviewExpenses} = useReviewFlaggedExpenses(); 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 submitCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]; + const approveCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.APPROVE]; + const payCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.PAY]; + const exportCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.EXPORT]; const hasAnyTodos = flaggedExpensesCount > 0 || submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; @@ -163,14 +162,13 @@ function ForYouSection() { ); const renderContent = () => { - const isInitialLoad = !hasLoadedApp && (isLoadingApp || isLoadingReportData || reportCounts === undefined); + const isInitialLoad = !hasLoadedApp && (isLoadingApp || isLoadingReportData); if (isInitialLoad) { const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'ForYouSection.ForYouSkeleton', isLoadingApp, isLoadingReportData, hasLoadedApp, - isReportCountsUndefined: reportCounts === undefined, }; return ; } diff --git a/src/selectors/Todos.ts b/src/selectors/Todos.ts index b0a628f1ed5a..f3a4ce6826f5 100644 --- a/src/selectors/Todos.ts +++ b/src/selectors/Todos.ts @@ -1,70 +1,5 @@ -import {shallowEqual} from 'fast-equals'; import type {OnyxEntry} from 'react-native-onyx'; -import CONST from '@src/CONST'; -import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; - -const EMPTY_TODOS_SINGLE_REPORT_IDS = Object.freeze({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: undefined, - [CONST.SEARCH.SEARCH_KEYS.PAY]: undefined, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: undefined, -}); - -const todosReportCountsSelector = (todos: OnyxEntry) => { - if (!todos) { - return undefined; - } - - return { - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: todos.reportsToSubmit.length, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: todos.reportsToApprove.length, - [CONST.SEARCH.SEARCH_KEYS.PAY]: todos.reportsToPay.length, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: todos.reportsToExport.length, - }; -}; - -type SingleReportIDs = { - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: string | undefined; - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: string | undefined; - [CONST.SEARCH.SEARCH_KEYS.PAY]: string | undefined; - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: string | undefined; -}; - -// Manual memoization: singleReportIDs is used as a whole object in useMemo dependency arrays -// in ForYouSection, so referential stability is needed to avoid cascading re-computation. -// todosReportCountsSelector doesn't need this because its values are immediately destructured -// into primitive variables (submitCount, approveCount, etc.). -let previousSingleReportIDs: SingleReportIDs = EMPTY_TODOS_SINGLE_REPORT_IDS as SingleReportIDs; - -const todosSingleReportIDsSelector = (todos: OnyxEntry) => { - if (!todos) { - return EMPTY_TODOS_SINGLE_REPORT_IDS; - } - - const submitReportID = todos.reportsToSubmit.length === 1 ? todos.reportsToSubmit.at(0)?.reportID : undefined; - const approveReportID = todos.reportsToApprove.length === 1 ? todos.reportsToApprove.at(0)?.reportID : undefined; - const payReportID = todos.reportsToPay.length === 1 ? todos.reportsToPay.at(0)?.reportID : undefined; - const exportReportID = todos.reportsToExport.length === 1 ? todos.reportsToExport.at(0)?.reportID : undefined; - - if (!submitReportID && !approveReportID && !payReportID && !exportReportID) { - previousSingleReportIDs = EMPTY_TODOS_SINGLE_REPORT_IDS; - return EMPTY_TODOS_SINGLE_REPORT_IDS; - } - - const newValue = { - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: submitReportID, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: approveReportID, - [CONST.SEARCH.SEARCH_KEYS.PAY]: payReportID, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: exportReportID, - }; - - if (shallowEqual(previousSingleReportIDs, newValue)) { - return previousSingleReportIDs; - } - - previousSingleReportIDs = newValue; - return newValue; -}; +import type {FlaggedExpensesDerivedValue} from '@src/types/onyx'; type FlaggedExpensesReview = { /** Total number of flagged expenses */ @@ -84,9 +19,8 @@ const EMPTY_FLAGGED_EXPENSES_REVIEW: FlaggedExpensesReview = Object.freeze({ 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. +// Manual memoization: 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 => { @@ -123,5 +57,4 @@ const flaggedExpensesReviewSelector = (flaggedExpensesValue: OnyxEntry ({ jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@hooks/useTodoCounts', () => jest.fn()); + const mockNavigateToTransactionThread = jest.fn(); jest.mock('@hooks/useNavigateToTransactionThread', () => jest.fn(() => mockNavigateToTransactionThread)); @@ -71,6 +74,7 @@ jest.mock('react-native-reanimated', () => { const mockNavigate = jest.mocked(Navigation.navigate); const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; +const mockUseTodoCounts = jest.mocked(useTodoCounts); const ACCOUNT_ID = 12345; @@ -82,6 +86,26 @@ const BASE_TODOS: TodosDerivedValue = { transactionsByReportID: {}, }; +// ForYouSection now derives its counts/single-IDs from the useTodoCounts hook instead of the TODOS derived value, +// so drive the component by controlling the hook's return value from the same report-bucket fixtures. +function setTodoCounts(todos: TodosDerivedValue) { + const singleReportID = (reports: TodosDerivedValue['reportsToSubmit']) => (reports.length === 1 ? reports.at(0)?.reportID : undefined); + mockUseTodoCounts.mockReturnValue({ + counts: { + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: todos.reportsToSubmit.length, + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: todos.reportsToApprove.length, + [CONST.SEARCH.SEARCH_KEYS.PAY]: todos.reportsToPay.length, + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: todos.reportsToExport.length, + }, + singleReportIDs: { + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: singleReportID(todos.reportsToSubmit), + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: singleReportID(todos.reportsToApprove), + [CONST.SEARCH.SEARCH_KEYS.PAY]: singleReportID(todos.reportsToPay), + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: singleReportID(todos.reportsToExport), + }, + }); +} + const EMPTY_FLAGGED_EXPENSES: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; function renderForYouSection() { @@ -113,6 +137,8 @@ describe('ForYouSection', () => { isInLandscapeMode: false, }); + setTodoCounts(BASE_TODOS); + await act(async () => { await Onyx.multiSet({ [ONYXKEYS.SESSION]: {accountID: ACCOUNT_ID, email: 'test@example.com'}, @@ -133,7 +159,7 @@ describe('ForYouSection', () => { describe('EmptyState', () => { it('renders EmptyState when there are no todos', async () => { await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + setTodoCounts(BASE_TODOS); await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, EMPTY_FLAGGED_EXPENSES); }); await waitForBatchedUpdatesWithAct(); @@ -148,7 +174,7 @@ 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, { + setTodoCounts({ ...BASE_TODOS, reportsToSubmit: [{reportID: '1'} as TodosDerivedValue['reportsToSubmit'][number]], }); @@ -164,7 +190,7 @@ describe('ForYouSection', () => { 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); + setTodoCounts(BASE_TODOS); await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], }); @@ -179,7 +205,7 @@ describe('ForYouSection', () => { it('renders with the count-N string when multiple expenses are flagged', async () => { await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + setTodoCounts(BASE_TODOS); await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { flaggedExpenses: [ {transactionID: 't1', reportID: 'r1'}, @@ -198,7 +224,7 @@ describe('ForYouSection', () => { it('renders the review row above submit/approve/pay/export rows', async () => { await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToSubmit: [{reportID: 's1'} as TodosDerivedValue['reportsToSubmit'][number]], reportsToApprove: [{reportID: 'a1'} as TodosDerivedValue['reportsToApprove'][number]], @@ -232,7 +258,7 @@ describe('ForYouSection', () => { it('exposes a Begin CTA and uses the ReceiptSearch icon asset', async () => { await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + setTodoCounts(BASE_TODOS); await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], }); @@ -254,7 +280,7 @@ describe('ForYouSection', () => { 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); + setTodoCounts(BASE_TODOS); await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { flaggedExpenses: [ {transactionID: 't1', reportID: 'r1'}, @@ -296,7 +322,7 @@ describe('ForYouSection', () => { 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); + setTodoCounts(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: ''}], @@ -319,7 +345,7 @@ describe('ForYouSection', () => { describe('navigation with multiple reports (search route)', () => { it('navigates to SEARCH_ROOT when submit has multiple reports', async () => { await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToSubmit: [{reportID: '1'} as TodosDerivedValue['reportsToSubmit'][number], {reportID: '2'} as TodosDerivedValue['reportsToSubmit'][number]], }); @@ -338,7 +364,7 @@ describe('ForYouSection', () => { it('navigates to SEARCH_ROOT when approve has multiple reports', async () => { await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToApprove: [{reportID: '3'} as TodosDerivedValue['reportsToApprove'][number], {reportID: '4'} as TodosDerivedValue['reportsToApprove'][number]], }); @@ -377,7 +403,7 @@ describe('ForYouSection', () => { it('navigates to EXPENSE_REPORT_RHP when submit has exactly one report on wide layout', async () => { const reportID = '42'; await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToSubmit: [{reportID} as TodosDerivedValue['reportsToSubmit'][number]], }); @@ -396,7 +422,7 @@ describe('ForYouSection', () => { it('navigates to EXPENSE_REPORT_RHP when approve has exactly one report on wide layout', async () => { const reportID = '55'; await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToApprove: [{reportID} as TodosDerivedValue['reportsToApprove'][number]], }); @@ -415,7 +441,7 @@ describe('ForYouSection', () => { it('navigates to EXPENSE_REPORT_RHP when pay has exactly one report on wide layout', async () => { const reportID = '66'; await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToPay: [{reportID} as TodosDerivedValue['reportsToPay'][number]], }); @@ -434,7 +460,7 @@ describe('ForYouSection', () => { it('navigates to EXPENSE_REPORT_RHP when export has exactly one report on wide layout', async () => { const reportID = '77'; await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToExport: [{reportID} as TodosDerivedValue['reportsToExport'][number]], }); @@ -471,7 +497,7 @@ describe('ForYouSection', () => { it('navigates to REPORT_WITH_ID when submit has exactly one report on narrow layout', async () => { const reportID = '99'; await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToSubmit: [{reportID} as TodosDerivedValue['reportsToSubmit'][number]], }); @@ -490,7 +516,7 @@ describe('ForYouSection', () => { it('navigates to REPORT_WITH_ID when approve has exactly one report on narrow layout', async () => { const reportID = '100'; await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + setTodoCounts({ ...BASE_TODOS, reportsToApprove: [{reportID} as TodosDerivedValue['reportsToApprove'][number]], }); diff --git a/tests/unit/selectors/TodosTest.ts b/tests/unit/selectors/TodosTest.ts index 9d90b76cb698..4ec9b4775739 100644 --- a/tests/unit/selectors/TodosTest.ts +++ b/tests/unit/selectors/TodosTest.ts @@ -1,217 +1,5 @@ -import todosReportCountsSelector, {flaggedExpensesReviewSelector, todosSingleReportIDsSelector} from '@selectors/Todos'; -import CONST from '@src/CONST'; -import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; - -describe('todosReportCountsSelector', () => { - it('returns undefined when todos is undefined', () => { - const result = todosReportCountsSelector(undefined); - - expect(result).toBeUndefined(); - }); - - it('returns correct counts when all report arrays are populated', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [{reportID: '1'}, {reportID: '2'}, {reportID: '3'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [{reportID: '4'}, {reportID: '5'}] as TodosDerivedValue['reportsToApprove'], - reportsToPay: [{reportID: '6'}] as TodosDerivedValue['reportsToPay'], - reportsToExport: [{reportID: '7'}, {reportID: '8'}, {reportID: '9'}, {reportID: '10'}] as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const result = todosReportCountsSelector(todos); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: 3, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: 2, - [CONST.SEARCH.SEARCH_KEYS.PAY]: 1, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: 4, - }); - }); - - it('returns zero counts when all report arrays are empty', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: {}, - }; - - const result = todosReportCountsSelector(todos); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: 0, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: 0, - [CONST.SEARCH.SEARCH_KEYS.PAY]: 0, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: 0, - }); - }); - - it('handles mixed empty and populated arrays', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [{reportID: '1'}, {reportID: '2'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [{reportID: '3'}] as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const result = todosReportCountsSelector(todos); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: 2, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: 0, - [CONST.SEARCH.SEARCH_KEYS.PAY]: 0, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: 1, - }); - }); - - it('handles large report counts correctly', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: Array(100) - .fill(null) - .map((_, i) => ({reportID: `submit_${i}`})) as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: Array(50) - .fill(null) - .map((_, i) => ({reportID: `approve_${i}`})) as TodosDerivedValue['reportsToApprove'], - reportsToPay: Array(25) - .fill(null) - .map((_, i) => ({reportID: `pay_${i}`})) as TodosDerivedValue['reportsToPay'], - reportsToExport: Array(75) - .fill(null) - .map((_, i) => ({reportID: `export_${i}`})) as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const result = todosReportCountsSelector(todos); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: 100, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: 50, - [CONST.SEARCH.SEARCH_KEYS.PAY]: 25, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: 75, - }); - }); -}); - -describe('todosSingleReportIDsSelector', () => { - it('returns EMPTY_TODOS_SINGLE_REPORT_IDS when todos is undefined', () => { - const result = todosSingleReportIDsSelector(undefined); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: undefined, - [CONST.SEARCH.SEARCH_KEYS.PAY]: undefined, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: undefined, - }); - }); - - it('returns EMPTY_TODOS_SINGLE_REPORT_IDS when all arrays are empty', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: {}, - }; - - const result = todosSingleReportIDsSelector(todos); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: undefined, - [CONST.SEARCH.SEARCH_KEYS.PAY]: undefined, - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: undefined, - }); - }); - - it('returns report ID when exactly one report exists in each array', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [{reportID: '1'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [{reportID: '2'}] as TodosDerivedValue['reportsToApprove'], - reportsToPay: [{reportID: '3'}] as TodosDerivedValue['reportsToPay'], - reportsToExport: [{reportID: '4'}] as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const result = todosSingleReportIDsSelector(todos); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: '1', - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: '2', - [CONST.SEARCH.SEARCH_KEYS.PAY]: '3', - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: '4', - }); - }); - - it('returns undefined for arrays with more than one report', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [{reportID: '1'}, {reportID: '2'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [{reportID: '3'}, {reportID: '4'}, {reportID: '5'}] as TodosDerivedValue['reportsToApprove'], - reportsToPay: [{reportID: '6'}] as TodosDerivedValue['reportsToPay'], - reportsToExport: [{reportID: '7'}, {reportID: '8'}] as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const result = todosSingleReportIDsSelector(todos); - - expect(result).toEqual({ - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined, - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: undefined, - [CONST.SEARCH.SEARCH_KEYS.PAY]: '6', - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: undefined, - }); - }); - - it('returns the same object reference when called twice with shallowly equal values (memoization)', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [{reportID: '10'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [] as unknown as TodosDerivedValue['reportsToApprove'], - reportsToPay: [] as unknown as TodosDerivedValue['reportsToPay'], - reportsToExport: [] as unknown as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const first = todosSingleReportIDsSelector(todos); - - const todosClone: TodosDerivedValue = { - reportsToSubmit: [{reportID: '10'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [] as unknown as TodosDerivedValue['reportsToApprove'], - reportsToPay: [] as unknown as TodosDerivedValue['reportsToPay'], - reportsToExport: [] as unknown as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const second = todosSingleReportIDsSelector(todosClone); - - expect(second).toBe(first); - }); - - it('returns a new object reference when values change (memoization invalidation)', () => { - const todos: TodosDerivedValue = { - reportsToSubmit: [{reportID: '20'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [] as unknown as TodosDerivedValue['reportsToApprove'], - reportsToPay: [] as unknown as TodosDerivedValue['reportsToPay'], - reportsToExport: [] as unknown as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const first = todosSingleReportIDsSelector(todos); - - const todosChanged: TodosDerivedValue = { - reportsToSubmit: [{reportID: '21'}] as TodosDerivedValue['reportsToSubmit'], - reportsToApprove: [] as unknown as TodosDerivedValue['reportsToApprove'], - reportsToPay: [] as unknown as TodosDerivedValue['reportsToPay'], - reportsToExport: [] as unknown as TodosDerivedValue['reportsToExport'], - transactionsByReportID: {}, - }; - - const second = todosSingleReportIDsSelector(todosChanged); - - expect(second).not.toBe(first); - expect(second[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe('21'); - }); -}); +import {flaggedExpensesReviewSelector} from '@selectors/Todos'; +import type {FlaggedExpensesDerivedValue} from '@src/types/onyx'; describe('flaggedExpensesReviewSelector', () => { it('returns count 0 and undefined identifiers when input is undefined', () => { From ffbd1386bcfd81070d67a33ca065a1db56eb31d0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 24 Jun 2026 15:49:04 +0200 Subject: [PATCH 04/16] Add useTodoSearchResults hook for single-category live to-do data --- src/hooks/useTodoSearchResults.ts | 164 ++++++++++++++++++++++++++++++ src/libs/TodosUtils.ts | 100 ++++++++++++++++-- 2 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useTodoSearchResults.ts diff --git a/src/hooks/useTodoSearchResults.ts b/src/hooks/useTodoSearchResults.ts new file mode 100644 index 000000000000..c6122eb8df6f --- /dev/null +++ b/src/hooks/useTodoSearchResults.ts @@ -0,0 +1,164 @@ +// We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live to-do data +// eslint-disable-next-line no-restricted-imports +import {useOnyx} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {SearchKey} from '@libs/SearchUIUtils'; +import {getTodoReportsForSearchKey} from '@libs/TodosUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, Report, ReportActions, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; + +type TodoSearchResultsData = SearchResults['data']; + +type TodoMetadata = { + /** Total number of transactions across all reports */ + count: number; + /** Sum of all report totals (in cents) */ + total: number; + /** Currency of the first report, used as reference currency */ + currency: string | undefined; +}; + +function computeMetadata(reports: Report[], transactionsByReportID: Record): TodoMetadata { + let count = 0; + let total = 0; + let currency: string | undefined; + + for (const report of reports) { + if (!report?.reportID) { + continue; + } + + const reportTransactions = transactionsByReportID[report.reportID]; + if (reportTransactions) { + count += reportTransactions.length; + + for (const transaction of reportTransactions) { + if (transaction.groupAmount) { + total -= transaction.groupAmount; + } + + if (currency === undefined && transaction.groupCurrency) { + currency = transaction.groupCurrency; + } + } + } + } + + return {count, total, currency}; +} + +/** + * Builds a SearchResults-compatible data object from the given reports and related data. + * This allows the search UI to use live Onyx data instead of snapshot data when viewing to-do results. + */ +function buildSearchResultsData( + reports: Report[], + transactionsByReportID: Record, + allPolicies: OnyxCollection, + allReportActions: OnyxCollection, + allReportNameValuePairs: OnyxCollection, + personalDetails: OnyxEntry, + transactionViolations: OnyxCollection, +): TodoSearchResultsData { + const data: TodoSearchResultsData = {}; + + for (const report of reports) { + if (!report?.reportID) { + continue; + } + data[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; + + if (report.policyID && allPolicies) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}` as const; + if (allPolicies[policyKey] && !data[policyKey]) { + data[policyKey] = allPolicies[policyKey]; + } + } + + // Add the report name value pairs for the chat report (needed for pay eligibility checks) + // Note: We don't add the chat report itself to match API behavior and avoid affecting shouldShowYear calculations + if (report.chatReportID && allReportNameValuePairs) { + const nvpKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}` as const; + if (allReportNameValuePairs[nvpKey] && !data[nvpKey]) { + data[nvpKey] = allReportNameValuePairs[nvpKey]; + } + } + + if (allReportActions) { + const actionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}` as const; + if (allReportActions[actionsKey] && !data[actionsKey]) { + data[actionsKey] = allReportActions[actionsKey]; + } + } + + // Add transactions for this report using the pre-computed mapping + const reportTransactions = transactionsByReportID[report.reportID]; + if (reportTransactions) { + for (const transaction of reportTransactions) { + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}` as const; + data[transactionKey] = transaction; + + if (transactionViolations) { + const violationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}` as const; + if (transactionViolations[violationsKey]) { + data[violationsKey] = transactionViolations[violationsKey]; + } + } + } + } + } + + if (personalDetails) { + // PersonalDetailsList allows null values whereas the snapshot type does not; merge via Object.assign to keep + // the live data faithful (including any null entries) without an unsafe narrowing assertion. + Object.assign(data, {[ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails}); + } + + return data; +} + +/** + * Builds live SearchResults-shaped data and metadata for a single to-do search (submit/approve/pay/export). + * Returns `undefined` (and skips all classification work) when `searchKey` is not provided, so the app-wide + * SearchResultsProvider only does work while a to-do search is actually being viewed. + */ +function useTodoSearchResults(searchKey: SearchKey | undefined): {data: TodoSearchResultsData; metadata: TodoMetadata} | undefined { + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + + if (!searchKey) { + return undefined; + } + + const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const login = personalDetailsList?.[userAccountID]?.login ?? session?.email ?? ''; + + const {reports, transactionsByReportID} = getTodoReportsForSearchKey(searchKey, { + allReports, + allTransactions, + allPolicies, + allReportNameValuePairs, + allReportActions, + allReportMetadata, + bankAccountList, + currentUserAccountID: userAccountID, + login, + }); + + const metadata = computeMetadata(reports, transactionsByReportID); + const data = buildSearchResultsData(reports, transactionsByReportID, allPolicies, allReportActions, allReportNameValuePairs, personalDetailsList, allTransactionViolations); + + return {data, metadata}; +} + +export default useTodoSearchResults; +export type {TodoMetadata, TodoSearchResultsData}; diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index dff9c6d7c756..f1e7e3b85c9a 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -4,6 +4,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {BankAccountList, Policy, Report, ReportActions, ReportMetadata, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import {isApproveAction, isExportAction, isPrimaryPayAction, isSubmitAction} from './ReportPrimaryActionUtils'; import {hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions} from './ReportUtils'; +import type {SearchKey} from './SearchUIUtils'; type CreateTodosReportsAndTransactionsParams = { allReports: OnyxCollection; @@ -17,9 +18,26 @@ type CreateTodosReportsAndTransactionsParams = { login: string; }; +/** + * Buckets every transaction by its report ID. Shared by every to-do consumer that needs per-report transactions. + */ +function buildTransactionsByReportID(allTransactions: OnyxCollection): Record { + const transactionsByReportID: Record = {}; + if (!allTransactions) { + return transactionsByReportID; + } + for (const transaction of Object.values(allTransactions)) { + if (!transaction?.reportID) { + continue; + } + (transactionsByReportID[transaction.reportID] ??= []).push(transaction); + } + return transactionsByReportID; +} + /** * Classifies every expense report into the four to-do buckets (submit/approve/pay/export) and indexes - * transactions by report ID. Shared by the TODOS derived value and the on-demand to-do hooks. + * transactions by report ID. Used by the TODOS derived value and the on-demand to-do count hook. */ function createTodosReportsAndTransactions({ allReports, @@ -43,14 +61,7 @@ function createTodosReportsAndTransactions({ return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport, transactionsByReportID}; } - if (allTransactions) { - for (const transaction of Object.values(allTransactions)) { - if (!transaction?.reportID) { - continue; - } - (transactionsByReportID[transaction.reportID] ??= []).push(transaction); - } - } + Object.assign(transactionsByReportID, buildTransactionsByReportID(allTransactions)); for (const report of reports) { if (!report?.reportID || report.type !== CONST.REPORT.TYPE.EXPENSE) { @@ -91,5 +102,76 @@ function createTodosReportsAndTransactions({ return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport, transactionsByReportID}; } +/** + * Classifies expense reports for a single to-do bucket only, running just that bucket's predicate. + * Used by the live search results path so viewing one to-do search doesn't compute the other buckets. + */ +function getTodoReportsForSearchKey( + searchKey: SearchKey, + { + allReports, + allTransactions, + allPolicies, + allReportNameValuePairs, + allReportActions, + allReportMetadata, + bankAccountList, + currentUserAccountID, + login, + }: CreateTodosReportsAndTransactionsParams, +) { + const reports: Report[] = []; + const transactionsByReportID = buildTransactionsByReportID(allTransactions); + + for (const report of Object.values(allReports ?? {})) { + if (!report?.reportID || report.type !== CONST.REPORT.TYPE.EXPENSE) { + continue; + } + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + const reportNameValuePair = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`]; + const reportTransactions = transactionsByReportID[report.reportID] ?? []; + const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; + const allExpensesHeld = hasOnlyHeldExpenses(reportTransactions); + + let matches = false; + switch (searchKey) { + case CONST.SEARCH.SEARCH_KEYS.SUBMIT: + matches = isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld; + break; + case CONST.SEARCH.SEARCH_KEYS.APPROVE: + matches = isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld; + break; + case CONST.SEARCH.SEARCH_KEYS.PAY: + matches = + isPrimaryPayAction({ + report, + reportTransactions, + currentUserAccountID, + currentUserLogin: login, + bankAccountList, + policy, + reportNameValuePairs: reportNameValuePair, + }) && + !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) && + !allExpensesHeld; + break; + case CONST.SEARCH.SEARCH_KEYS.EXPORT: { + const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); + matches = isExportAction(report, login, policy, reportActions) && policy?.exporter === login; + break; + } + default: + matches = false; + } + + if (matches) { + reports.push(report); + } + } + + return {reports, transactionsByReportID}; +} + export default createTodosReportsAndTransactions; +export {buildTransactionsByReportID, getTodoReportsForSearchKey}; export type {CreateTodosReportsAndTransactionsParams}; From 7417fcefbd4d4f157358e76288d1cbe852247ab8 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 24 Jun 2026 15:52:15 +0200 Subject: [PATCH 05/16] Switch SearchResultsProvider to useTodoSearchResults and remove useTodos --- .../Search/SearchResultsProvider.tsx | 10 +- src/hooks/useTodos.ts | 157 --------------- tests/unit/useTodosTest.ts | 178 ------------------ 3 files changed, 5 insertions(+), 340 deletions(-) delete mode 100644 src/hooks/useTodos.ts delete mode 100644 tests/unit/useTodosTest.ts diff --git a/src/components/Search/SearchResultsProvider.tsx b/src/components/Search/SearchResultsProvider.tsx index 2e6467f67539..5e80eb20d733 100644 --- a/src/components/Search/SearchResultsProvider.tsx +++ b/src/components/Search/SearchResultsProvider.tsx @@ -5,7 +5,7 @@ import React, {useState} from 'react'; // so it would add nothing for this read. Use the raw react-native-onyx hook directly. // eslint-disable-next-line no-restricted-imports import {useOnyx} from 'react-native-onyx'; -import useTodos from '@hooks/useTodos'; +import useTodoSearchResults from '@hooks/useTodoSearchResults'; import {isTodoSearch} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,16 +36,16 @@ function SearchResultsProvider({children}: SearchResultsProviderProps) { const {currentSearchHash, currentSearchKey, currentSearchQueryJSON, suggestedSearches} = useSearchQueryContext(); const currentRecentSearchHash = currentSearchQueryJSON?.recentSearchHash ?? -1; - const todoSearchResultsData = useTodos(); const [snapshotSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}`); const shouldUseLiveData = !!currentSearchKey && isTodoSearch(currentRecentSearchHash, suggestedSearches); + const liveTodoData = useTodoSearchResults(shouldUseLiveData ? currentSearchKey : undefined); - // If viewing a to-do search, use live data from useTodos, otherwise return the snapshot data - // We do this so that we can show the counters for the to-do search results without visiting the specific to-do page, e.g. show `Approve [3]` while viewing the `Submit` to-do search. + // If viewing a to-do search, use live Onyx data for the active category, otherwise return the snapshot data. + // We do this so the results stay fresh as the user acts on reports, instead of showing a stale server snapshot. let currentSearchResults; if (shouldUseLiveData) { - const liveData = todoSearchResultsData[currentSearchKey as keyof typeof todoSearchResultsData]; + const liveData = liveTodoData ?? {data: {}, metadata: {count: 0, total: 0, currency: undefined}}; const searchInfo: SearchResultsInfo = { ...(snapshotSearchResults?.search ?? defaultSearchInfo), count: liveData.metadata.count, diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts deleted file mode 100644 index bbcbf46baddc..000000000000 --- a/src/hooks/useTodos.ts +++ /dev/null @@ -1,157 +0,0 @@ -// We need direct access to useOnyx from react-native-onyx to avoid using snapshots for live to-do data -// eslint-disable-next-line no-restricted-imports -import {useOnyx} from 'react-native-onyx'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Policy, Report, ReportActions, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; - -type TodoSearchResultsData = SearchResults['data']; - -type TodoMetadata = { - /** Total number of transactions across all reports */ - count: number; - /** Sum of all report totals (in cents) */ - total: number; - /** Currency of the first report, used as reference currency */ - currency: string | undefined; -}; - -function computeMetadata(reports: Report[], transactionsByReportID: Record): TodoMetadata { - let count = 0; - let total = 0; - let currency: string | undefined; - - for (const report of reports) { - if (!report?.reportID) { - continue; - } - - const reportTransactions = transactionsByReportID[report.reportID]; - if (reportTransactions) { - count += reportTransactions.length; - - for (const transaction of reportTransactions) { - if (transaction.groupAmount) { - total -= transaction.groupAmount; - } - - if (currency === undefined && transaction.groupCurrency) { - currency = transaction.groupCurrency; - } - } - } - } - - return {count, total, currency}; -} - -/** - * Builds a SearchResults-compatible data object from the given reports and related data. - * This allows the search UI to use live Onyx data instead of snapshot data when viewing to-do results. - */ -function buildSearchResultsData( - reports: Report[], - transactionsByReportID: Record, - allPolicies: Record | undefined, - allReportActions: Record | undefined, - allReportNameValuePairs: Record | undefined, - personalDetails: PersonalDetailsList | undefined, - transactionViolations: Record | undefined, -): TodoSearchResultsData { - const data: Record = {}; - - for (const report of reports) { - if (!report?.reportID) { - continue; - } - data[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; - - if (report.policyID && allPolicies) { - const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`; - if (allPolicies[policyKey] && !data[policyKey]) { - data[policyKey] = allPolicies[policyKey]; - } - } - - // Add the report name value pairs for the chat report (needed for pay eligibility checks) - // Note: We don't add the chat report itself to match API behavior and avoid affecting shouldShowYear calculations - if (report.chatReportID && allReportNameValuePairs) { - const nvpKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`; - if (allReportNameValuePairs[nvpKey] && !data[nvpKey]) { - data[nvpKey] = allReportNameValuePairs[nvpKey]; - } - } - - if (allReportActions) { - const actionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`; - if (allReportActions[actionsKey] && !data[actionsKey]) { - data[actionsKey] = allReportActions[actionsKey]; - } - } - - // Add transactions for this report using the pre-computed mapping - const reportTransactions = transactionsByReportID[report.reportID]; - if (reportTransactions) { - for (const transaction of reportTransactions) { - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - data[transactionKey] = transaction; - - if (transactionViolations) { - const violationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`; - if (transactionViolations[violationsKey]) { - data[violationsKey] = transactionViolations[violationsKey]; - } - } - } - } - } - - if (personalDetails) { - data[ONYXKEYS.PERSONAL_DETAILS_LIST] = personalDetails; - } - - return data as TodoSearchResultsData; -} - -export default function useTodos() { - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - - const [todosReports] = useOnyx(ONYXKEYS.DERIVED.TODOS); - - const transactionsByReportID = todosReports?.transactionsByReportID ?? CONST.EMPTY_OBJECT; - - // Build SearchResults-formatted data for each to-do category - const buildData = (reports?: Report[]): {data: TodoSearchResultsData; metadata: TodoMetadata} => { - if (!reports?.length) { - // Return empty object like the Search API would when there's no data - return { - data: CONST.EMPTY_OBJECT as TodoSearchResultsData, - metadata: {count: 0, total: 0, currency: undefined}, - }; - } - - const metadata = computeMetadata(reports, transactionsByReportID); - const data = buildSearchResultsData( - reports, - transactionsByReportID, - allPolicies as Record | undefined, - allReportActions as Record | undefined, - allReportNameValuePairs as Record | undefined, - personalDetailsList, - allTransactionViolations as Record | undefined, - ); - - return {data, metadata}; - }; - - return { - [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: buildData(todosReports?.reportsToSubmit), - [CONST.SEARCH.SEARCH_KEYS.APPROVE]: buildData(todosReports?.reportsToApprove), - [CONST.SEARCH.SEARCH_KEYS.PAY]: buildData(todosReports?.reportsToPay), - [CONST.SEARCH.SEARCH_KEYS.EXPORT]: buildData(todosReports?.reportsToExport), - }; -} diff --git a/tests/unit/useTodosTest.ts b/tests/unit/useTodosTest.ts deleted file mode 100644 index 1bf14ffdbee8..000000000000 --- a/tests/unit/useTodosTest.ts +++ /dev/null @@ -1,178 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; -import Onyx from 'react-native-onyx'; -import useTodos from '@hooks/useTodos'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, TodosDerivedValue, Transaction} from '@src/types/onyx'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; - -// This keeps the error "@rnmapbox/maps native code not available." from causing the tests to fail -jest.mock('@components/ConfirmedRoute.tsx'); - -describe('useTodos', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - - describe('data transformation', () => { - it('returns empty data and metadata when no todos exist', async () => { - const {result} = renderHook(() => useTodos()); - await waitForBatchedUpdatesWithAct(); - - const {current} = result; - - expect(current[CONST.SEARCH.SEARCH_KEYS.SUBMIT].data).toEqual({}); - expect(current[CONST.SEARCH.SEARCH_KEYS.SUBMIT].metadata).toEqual({ - count: 0, - total: 0, - currency: undefined, - }); - - expect(current[CONST.SEARCH.SEARCH_KEYS.APPROVE].data).toEqual({}); - expect(current[CONST.SEARCH.SEARCH_KEYS.PAY].data).toEqual({}); - expect(current[CONST.SEARCH.SEARCH_KEYS.EXPORT].data).toEqual({}); - }); - - it('transforms todos reports into SearchResults format', async () => { - const mockReport: Report = { - reportID: 'report1', - policyID: 'policy1', - chatReportID: 'chat1', - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: 1, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - } as Report; - - const mockTodos: TodosDerivedValue = { - reportsToSubmit: [mockReport], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: {}, - }; - await Onyx.set(ONYXKEYS.DERIVED.TODOS, mockTodos); - await waitForBatchedUpdatesWithAct(); - - const {result} = renderHook(() => useTodos()); - await waitForBatchedUpdatesWithAct(); - - const submitData = result.current[CONST.SEARCH.SEARCH_KEYS.SUBMIT].data; - expect(submitData[`${ONYXKEYS.COLLECTION.REPORT}report1`]).toEqual(mockReport); - }); - - it('computes metadata correctly with transactions', async () => { - const mockReport: Report = { - reportID: 'report1', - type: CONST.REPORT.TYPE.EXPENSE, - } as Report; - - const mockTransaction: Transaction = { - transactionID: 'trans1', - reportID: 'report1', - groupAmount: 100, - groupCurrency: 'USD', - } as Transaction; - - const mockTodos: TodosDerivedValue = { - reportsToSubmit: [mockReport], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: { - report1: [mockTransaction], - }, - }; - - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.TODOS, mockTodos); - await waitForBatchedUpdatesWithAct(); - }); - - const {result} = renderHook(() => useTodos()); - await waitForBatchedUpdatesWithAct(); - - const submitMetadata = result.current[CONST.SEARCH.SEARCH_KEYS.SUBMIT].metadata; - expect(submitMetadata.count).toBe(1); - expect(submitMetadata.total).toBe(-100); // groupAmount is subtracted - expect(submitMetadata.currency).toBe('USD'); - }); - - it('includes transactions in the data output', async () => { - const mockReport: Report = { - reportID: 'report1', - type: CONST.REPORT.TYPE.EXPENSE, - } as Report; - - const mockTransaction: Transaction = { - transactionID: 'trans1', - reportID: 'report1', - } as Transaction; - - const mockTodos: TodosDerivedValue = { - reportsToSubmit: [mockReport], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: { - report1: [mockTransaction], - }, - }; - - await Onyx.set(ONYXKEYS.DERIVED.TODOS, mockTodos); - await waitForBatchedUpdatesWithAct(); - - const {result} = renderHook(() => useTodos()); - await waitForBatchedUpdatesWithAct(); - - const submitData = result.current[CONST.SEARCH.SEARCH_KEYS.SUBMIT].data; - expect(submitData[`${ONYXKEYS.COLLECTION.TRANSACTION}trans1`]).toEqual(mockTransaction); - }); - - it('handles multiple transactions for a single report', async () => { - const mockReport: Report = { - reportID: 'report1', - type: CONST.REPORT.TYPE.EXPENSE, - } as Report; - - const mockTransaction1: Transaction = { - transactionID: 'trans1', - reportID: 'report1', - groupAmount: 50, - groupCurrency: 'USD', - } as Transaction; - - const mockTransaction2: Transaction = { - transactionID: 'trans2', - reportID: 'report1', - groupAmount: 75, - groupCurrency: 'USD', - } as Transaction; - - const mockTodos: TodosDerivedValue = { - reportsToSubmit: [mockReport], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: { - report1: [mockTransaction1, mockTransaction2], - }, - }; - - await Onyx.set(ONYXKEYS.DERIVED.TODOS, mockTodos); - await waitForBatchedUpdatesWithAct(); - - const {result} = renderHook(() => useTodos()); - await waitForBatchedUpdatesWithAct(); - - const submitMetadata = result.current[CONST.SEARCH.SEARCH_KEYS.SUBMIT].metadata; - expect(submitMetadata.count).toBe(2); - expect(submitMetadata.total).toBe(-125); // -(50 + 75) - }); - }); -}); From ef0b379f2a8830d522b8335e8a598ad17d2f082e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 26 Jun 2026 09:12:56 +0200 Subject: [PATCH 06/16] Remove unused TODOS derived value --- Mobile-Expensify | 2 +- src/ONYXKEYS.ts | 2 - .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - src/libs/actions/OnyxDerived/configs/todos.ts | 43 ------------------- src/types/onyx/DerivedValues.ts | 30 ------------- src/types/onyx/index.ts | 4 -- 6 files changed, 1 insertion(+), 82 deletions(-) delete mode 100644 src/libs/actions/OnyxDerived/configs/todos.ts diff --git a/Mobile-Expensify b/Mobile-Expensify index 8dfb01854680..57159c04a42e 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 8dfb01854680e9327e891ad42984fe3515905962 +Subproject commit 57159c04a42eae531716c4b4a837cfb523551653 diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 37ab59480ea1..46547b6e7d86 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1194,7 +1194,6 @@ const ONYXKEYS = { NON_PERSONAL_AND_WORKSPACE_CARD_LIST: 'nonPersonalAndWorkspaceCardList', PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', - TODOS: 'todos', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', FLAGGED_EXPENSES: 'flaggedExpenses', }, @@ -1682,7 +1681,6 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.NonPersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; - [ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: OnyxTypes.FlaggedExpensesDerivedValue; }; diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 99de4c86cde4..0dbf9a399f2a 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -8,7 +8,6 @@ import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCa import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; import sortedReportActionsConfig from './configs/sortedReportActions'; -import todosConfig from './configs/todos'; import visibleReportActionsConfig from './configs/visibleReportActions'; import type {OnyxDerivedValueConfig} from './types'; @@ -24,7 +23,6 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: nonPersonalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, - [ONYXKEYS.DERIVED.TODOS]: todosConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, [ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: flaggedExpensesConfig, } as const satisfies { diff --git a/src/libs/actions/OnyxDerived/configs/todos.ts b/src/libs/actions/OnyxDerived/configs/todos.ts deleted file mode 100644 index 121de1ccb206..000000000000 --- a/src/libs/actions/OnyxDerived/configs/todos.ts +++ /dev/null @@ -1,43 +0,0 @@ -import createTodosReportsAndTransactions from '@libs/TodosUtils'; -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.TODOS, - dependencies: [ - ONYXKEYS.COLLECTION.REPORT, - ONYXKEYS.COLLECTION.POLICY, - ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - ONYXKEYS.COLLECTION.TRANSACTION, - ONYXKEYS.COLLECTION.REPORT_ACTIONS, - ONYXKEYS.COLLECTION.REPORT_METADATA, - ONYXKEYS.BANK_ACCOUNT_LIST, - ONYXKEYS.SESSION, - ONYXKEYS.PERSONAL_DETAILS_LIST, - ], - compute: ([allReports, allPolicies, allReportNameValuePairs, allTransactions, allReportActions, allReportMetadata, bankAccountList, session, personalDetailsList]) => { - const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; - const login = personalDetailsList?.[userAccountID]?.login ?? session?.email ?? ''; - - const {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport, transactionsByReportID} = createTodosReportsAndTransactions({ - allReports, - allTransactions, - allPolicies, - allReportNameValuePairs, - allReportActions, - allReportMetadata, - bankAccountList, - currentUserAccountID: userAccountID, - login, - }); - - return { - reportsToSubmit, - reportsToApprove, - reportsToPay, - reportsToExport, - transactionsByReportID, - }; - }, -}); diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 00e5abf4ad0c..fbc56adc9a2e 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -229,34 +229,6 @@ type CardFeedErrorsDerivedValue = CardFeedErrors; */ type NonPersonalAndWorkspaceCardListDerivedValue = CardList; -/** - * Metadata for todo search results. - */ -type TodoMetadata = { - /** Total number of transactions across all reports */ - count: number; - /** Sum of all report totals (in cents) */ - total: number; - /** Currency of the first report, used as reference currency */ - currency: string | undefined; -}; - -/** - * The derived value for todos. - */ -type TodosDerivedValue = { - /** Reports that need to be submitted */ - reportsToSubmit: Report[]; - /** Reports that need to be approved */ - reportsToApprove: Report[]; - /** Reports that need to be paid */ - reportsToPay: Report[]; - /** Reports that need to be exported */ - reportsToExport: Report[]; - /** Transactions grouped by report ID */ - transactionsByReportID: Record; -}; - /** * The derived value for flagged expenses. * @@ -302,8 +274,6 @@ export type { NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, - TodosDerivedValue, - TodoMetadata, FlaggedExpensesDerivedValue, CardFeedErrorsObject, CardFeedErrorState, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 2f2461e39583..6e5cd1a92867 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -55,8 +55,6 @@ import type { ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue, SortedReportActionsDerivedValue, - TodoMetadata, - TodosDerivedValue, VisibleReportActionsDerivedValue, } from './DerivedValues'; import type DeviceBiometrics from './DeviceBiometrics'; @@ -399,8 +397,6 @@ export type { NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, - TodosDerivedValue, - TodoMetadata, FlaggedExpensesDerivedValue, ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, From 1c4c7bce50c01c367bedfc4523079debb99467c3 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 26 Jun 2026 11:58:42 +0200 Subject: [PATCH 07/16] Freeze useTodoCounts when consumer is disabled --- src/hooks/useTodoCounts.ts | 35 +++++++++++++++++++++-- src/pages/Search/SearchTypeMenuNarrow.tsx | 5 ++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/hooks/useTodoCounts.ts b/src/hooks/useTodoCounts.ts index 9c119b02fd9d..6f75dc177b11 100644 --- a/src/hooks/useTodoCounts.ts +++ b/src/hooks/useTodoCounts.ts @@ -1,3 +1,4 @@ +import {useState} from 'react'; // We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live to-do data // eslint-disable-next-line no-restricted-imports import {useOnyx} from 'react-native-onyx'; @@ -23,8 +24,11 @@ type TodoSingleReportIDs = { * Computes live to-do report counts and, for each bucket that contains exactly one report, that report's ID. * Runs the to-do classification on demand from live Onyx data, only while a consumer is mounted, replacing the * always-on TODOS derived value for count consumers. + * + * Pass `enabled: false` to freeze the hook: while disabled it skips the expensive classification and returns the + * last computed result, so e.g. an unfocused screen stops recomputing to-do counts on background Onyx writes. */ -function useTodoCounts(): {counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} { +function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} { const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); @@ -35,6 +39,14 @@ function useTodoCounts(): {counts: TodoCounts; singleReportIDs: TodoSingleReport const [session] = useOnyx(ONYXKEYS.SESSION); const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + // Holds the most recent result so a frozen (inactive) consumer can keep returning it without recomputing. + const [frozen, setFrozen] = useState<{signature: string; value: {counts: TodoCounts; singleReportIDs: TodoSingleReportIDs}} | null>(null); + + // While frozen, reuse the last captured result and skip the expensive classification below. + if (!enabled && frozen) { + return frozen.value; + } + const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; const login = personalDetailsList?.[userAccountID]?.login ?? session?.email ?? ''; @@ -64,7 +76,26 @@ function useTodoCounts(): {counts: TodoCounts; singleReportIDs: TodoSingleReport [CONST.SEARCH.SEARCH_KEYS.EXPORT]: reportsToExport.length === 1 ? reportsToExport.at(0)?.reportID : undefined, }; - return {counts, singleReportIDs}; + const value = {counts, singleReportIDs}; + + // Capture the latest result so it can be returned verbatim once the consumer freezes. Guarded by a content + // signature so we only re-render when the counts/IDs actually change (avoids a setState-during-render loop). + const signature = [ + counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT], + counts[CONST.SEARCH.SEARCH_KEYS.APPROVE], + counts[CONST.SEARCH.SEARCH_KEYS.PAY], + counts[CONST.SEARCH.SEARCH_KEYS.EXPORT], + singleReportIDs[CONST.SEARCH.SEARCH_KEYS.SUBMIT], + singleReportIDs[CONST.SEARCH.SEARCH_KEYS.APPROVE], + singleReportIDs[CONST.SEARCH.SEARCH_KEYS.PAY], + singleReportIDs[CONST.SEARCH.SEARCH_KEYS.EXPORT], + ].join('|'); + + if (frozen?.signature !== signature) { + setFrozen({signature, value}); + } + + return value; } export default useTodoCounts; diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index dcd5de1d28e8..1d77798487dd 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -1,7 +1,7 @@ // NOTE: This component has a static twin in SearchPageNarrow/StaticSearchTypeMenu.tsx // used for fast perceived performance. If you change the UI here, verify the // static version still looks visually identical. -import {useNavigation} from '@react-navigation/native'; +import {useIsFocused, useNavigation} from '@react-navigation/native'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import type BaseModalProps from '@components/Modal/types'; @@ -92,7 +92,8 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [workspaceCardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); - const {counts: reportCounts} = useTodoCounts(); + const isFocused = useIsFocused(); + const {counts: reportCounts} = useTodoCounts(isFocused); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); From 16e570905d0d6a35191f58a3112005c91bf0ccd5 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 26 Jun 2026 12:16:34 +0200 Subject: [PATCH 08/16] Freeze useTodoCounts in ForYouSection when home is unfocused --- src/pages/home/ForYouSection/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index 7c3f819a6064..1d2d799d2374 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import BaseWidgetItem from '@components/BaseWidgetItem'; @@ -33,7 +34,8 @@ function ForYouSection() { // Gating the skeleton on it prevents the section from flashing skeleton on every foreground/reconnect // when IS_LOADING_REPORT_DATA is optimistically set to true by ReconnectApp. const [hasLoadedApp = false] = useOnyx(ONYXKEYS.HAS_LOADED_APP); - const {counts: reportCounts, singleReportIDs} = useTodoCounts(); + const isFocused = useIsFocused(); + const {counts: reportCounts, singleReportIDs} = useTodoCounts(isFocused); const {count: flaggedExpensesCount, reviewExpenses} = useReviewFlaggedExpenses(); const icons = useMemoizedLazyExpensifyIcons(['ReceiptSearch', 'MoneyBag', 'Send', 'ThumbsUp', 'Export']); From e681dbb0c0df82c77299825bfac30a52eabee86f Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 26 Jun 2026 13:18:10 +0200 Subject: [PATCH 09/16] Add useTodoCounts tests and fix TODOS derived-value removal fallout --- Mobile-Expensify | 2 +- config/eslint/eslint.seatbelt.tsv | 1 + ...DynamicNewReportWorkspaceSelectionPage.tsx | 1 + tests/ui/ForYouSectionTest.tsx | 60 +- tests/unit/OnyxDerivedTest.tsx | 574 +----------------- tests/unit/useTodoCountsTest.tsx | 470 ++++++++++++++ 6 files changed, 515 insertions(+), 593 deletions(-) create mode 100644 tests/unit/useTodoCountsTest.tsx diff --git a/Mobile-Expensify b/Mobile-Expensify index 57159c04a42e..27a47d6c0407 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 57159c04a42eae531716c4b4a837cfb523551653 +Subproject commit 27a47d6c0407efb43403da4062bc711734fe73fc diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index ee2397f9ea58..72b93dfdef64 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -2025,6 +2025,7 @@ "../../tests/unit/useSubPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/useSubStepTest.tsx" "@typescript-eslint/no-deprecated/useSubStep" 13 "../../tests/unit/useSyncFocusTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../tests/unit/useTodoCountsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/useTodosTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/useTransactionViolationsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 "../../tests/unit/useWorkspacesTabIndicatorStatusTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index 6485f5c1ee25..a64bd9cee547 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -75,6 +75,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); diff --git a/tests/ui/ForYouSectionTest.tsx b/tests/ui/ForYouSectionTest.tsx index bcaa0a008d49..cf07ab5c8907 100644 --- a/tests/ui/ForYouSectionTest.tsx +++ b/tests/ui/ForYouSectionTest.tsx @@ -1,3 +1,4 @@ +import type * as ReactNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -8,7 +9,7 @@ import ForYouSection from '@pages/home/ForYouSection'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; +import type {FlaggedExpensesDerivedValue} from '@src/types/onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@libs/Navigation/Navigation', () => ({ @@ -21,6 +22,17 @@ jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/useTodoCounts', () => jest.fn()); +// ForYouSection calls useIsFocused() to freeze useTodoCounts when unfocused; this test renders it outside a +// NavigationContainer, so stub the focus hook (useTodoCounts is mocked, so the focus value itself is irrelevant). +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useIsFocused: jest.fn(() => true), + }; +}); + const mockNavigateToTransactionThread = jest.fn(); jest.mock('@hooks/useNavigateToTransactionThread', () => jest.fn(() => mockNavigateToTransactionThread)); @@ -78,18 +90,26 @@ const mockUseTodoCounts = jest.mocked(useTodoCounts); const ACCOUNT_ID = 12345; -const BASE_TODOS: TodosDerivedValue = { +// ForYouSection now derives its counts/single-IDs from the useTodoCounts hook (which is mocked here) instead of the +// removed TODOS derived value, so the fixtures only need the report buckets the hook's return is computed from. +type TodoReport = {reportID: string}; +type TodoFixture = { + reportsToSubmit: TodoReport[]; + reportsToApprove: TodoReport[]; + reportsToPay: TodoReport[]; + reportsToExport: TodoReport[]; +}; + +const BASE_TODOS: TodoFixture = { reportsToSubmit: [], reportsToApprove: [], reportsToPay: [], reportsToExport: [], - transactionsByReportID: {}, }; -// ForYouSection now derives its counts/single-IDs from the useTodoCounts hook instead of the TODOS derived value, -// so drive the component by controlling the hook's return value from the same report-bucket fixtures. -function setTodoCounts(todos: TodosDerivedValue) { - const singleReportID = (reports: TodosDerivedValue['reportsToSubmit']) => (reports.length === 1 ? reports.at(0)?.reportID : undefined); +// Drive the component by controlling the mocked hook's return value from the report-bucket fixtures. +function setTodoCounts(todos: TodoFixture) { + const singleReportID = (reports: TodoReport[]) => (reports.length === 1 ? reports.at(0)?.reportID : undefined); mockUseTodoCounts.mockReturnValue({ counts: { [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: todos.reportsToSubmit.length, @@ -176,7 +196,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToSubmit: [{reportID: '1'} as TodosDerivedValue['reportsToSubmit'][number]], + reportsToSubmit: [{reportID: '1'}], }); await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, EMPTY_FLAGGED_EXPENSES); }); @@ -226,10 +246,10 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...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]], + reportsToSubmit: [{reportID: 's1'}], + reportsToApprove: [{reportID: 'a1'}], + reportsToPay: [{reportID: 'p1'}], + reportsToExport: [{reportID: 'e1'}], }); await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], @@ -347,7 +367,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToSubmit: [{reportID: '1'} as TodosDerivedValue['reportsToSubmit'][number], {reportID: '2'} as TodosDerivedValue['reportsToSubmit'][number]], + reportsToSubmit: [{reportID: '1'}, {reportID: '2'}], }); }); await waitForBatchedUpdatesWithAct(); @@ -366,7 +386,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToApprove: [{reportID: '3'} as TodosDerivedValue['reportsToApprove'][number], {reportID: '4'} as TodosDerivedValue['reportsToApprove'][number]], + reportsToApprove: [{reportID: '3'}, {reportID: '4'}], }); }); await waitForBatchedUpdatesWithAct(); @@ -405,7 +425,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToSubmit: [{reportID} as TodosDerivedValue['reportsToSubmit'][number]], + reportsToSubmit: [{reportID}], }); }); await waitForBatchedUpdatesWithAct(); @@ -424,7 +444,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToApprove: [{reportID} as TodosDerivedValue['reportsToApprove'][number]], + reportsToApprove: [{reportID}], }); }); await waitForBatchedUpdatesWithAct(); @@ -443,7 +463,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToPay: [{reportID} as TodosDerivedValue['reportsToPay'][number]], + reportsToPay: [{reportID}], }); }); await waitForBatchedUpdatesWithAct(); @@ -462,7 +482,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToExport: [{reportID} as TodosDerivedValue['reportsToExport'][number]], + reportsToExport: [{reportID}], }); }); await waitForBatchedUpdatesWithAct(); @@ -499,7 +519,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToSubmit: [{reportID} as TodosDerivedValue['reportsToSubmit'][number]], + reportsToSubmit: [{reportID}], }); }); await waitForBatchedUpdatesWithAct(); @@ -518,7 +538,7 @@ describe('ForYouSection', () => { await act(async () => { setTodoCounts({ ...BASE_TODOS, - reportsToApprove: [{reportID} as TodosDerivedValue['reportsToApprove'][number]], + reportsToApprove: [{reportID}], }); }); await waitForBatchedUpdatesWithAct(); diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index 63311c1e21b8..7db8542521c1 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -1,14 +1,13 @@ /* eslint-disable @typescript-eslint/naming-convention */ import Onyx from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry, OnyxMultiSetInput} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import reportAttributes from '@libs/actions/OnyxDerived/configs/reportAttributes'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, Transaction} from '@src/types/onyx'; -import type {ACHAccount} from '@src/types/onyx/Policy'; +import type {Report} from '@src/types/onyx'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import {createRandomCompanyCard, createRandomExpensifyCard} from '../utils/collections/card'; import {createRandomReport} from '../utils/collections/reports'; @@ -585,573 +584,4 @@ describe('OnyxDerived', () => { }); }); }); - - describe('todos', () => { - beforeAll(async () => { - // Initialize dependency keys so Onyx.clear() in beforeEach triggers derived value recomputation - await Onyx.set(ONYXKEYS.SESSION, {}); - await waitForBatchedUpdates(); - }); - - const CURRENT_USER_ACCOUNT_ID = 1; - const CURRENT_USER_EMAIL = 'tester@mail.com'; - const OTHER_USER_ACCOUNT_ID = 2; - - const POLICY_ID = 'policy123'; - const POLICY_WITH_CONNECTION_ID = 'policy_with_connection'; - - // Helper functions that use collection utilities as base but allow precise control - const createMockReport = (reportID: string, overrides: Partial = {}): Report => { - return { - reportID, - chatReportID: `chat_${reportID}`, - policyID: POLICY_ID, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - managerID: OTHER_USER_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - type: CONST.REPORT.TYPE.EXPENSE, - parentReportID: '123', - parentReportActionID: '456', - reportName: 'Test Report', - currency: 'USD', - isOwnPolicyExpenseChat: false, - isPinned: false, - isWaitingOnBankAccount: false, - ...overrides, - }; - }; - - const createMockPolicy = (policyID: string, overrides: Partial = {}): OnyxEntry => { - return { - id: policyID, - name: 'Test Policy', - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - role: CONST.POLICY.ROLE.USER, - ...overrides, - } as Policy; - }; - - const createMockTransaction = (transactionID: string, reportID: string, overrides: Partial = {}): Transaction => { - return { - transactionID, - reportID, - amount: 100, - modifiedAmount: 0, - reimbursable: true, - status: CONST.TRANSACTION.STATUS.POSTED, - currency: 'USD', - merchant: 'Test Merchant', - created: '2024-01-01', - ...overrides, - } as Transaction; - }; - - it('returns empty object when dependencies are not set', async () => { - await waitForBatchedUpdates(); - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - expect(todos).toEqual({ - reportsToSubmit: [], - reportsToApprove: [], - reportsToPay: [], - reportsToExport: [], - transactionsByReportID: {}, - }); - }); - - describe('excludes reports with all expenses on hold', () => { - const HELD_SUBMIT_REPORT_ID = 'held_submit_1'; - const HELD_APPROVE_REPORT_ID = 'held_approve_1'; - const HELD_PAY_REPORT_ID = 'held_pay_1'; - - beforeEach(async () => { - const submitReport = createMockReport(HELD_SUBMIT_REPORT_ID, { - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - }); - const approveReport = createMockReport(HELD_APPROVE_REPORT_ID, { - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - managerID: CURRENT_USER_ACCOUNT_ID, - }); - const payReport = createMockReport(HELD_PAY_REPORT_ID, { - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - managerID: CURRENT_USER_ACCOUNT_ID, - total: -100, - }); - - const policy = createMockPolicy(POLICY_ID, { - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - role: CONST.POLICY.ROLE.ADMIN, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - }); - - const heldOverride: Partial = {comment: {hold: 'HOLD_ACTION_ID'}}; - - await Onyx.multiSet({ - [ONYXKEYS.SESSION]: { - email: CURRENT_USER_EMAIL, - accountID: CURRENT_USER_ACCOUNT_ID, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: policy, - [`${ONYXKEYS.COLLECTION.REPORT}${HELD_SUBMIT_REPORT_ID}`]: submitReport, - [`${ONYXKEYS.COLLECTION.REPORT}${HELD_APPROVE_REPORT_ID}`]: approveReport, - [`${ONYXKEYS.COLLECTION.REPORT}${HELD_PAY_REPORT_ID}`]: payReport, - [`${ONYXKEYS.COLLECTION.TRANSACTION}trans_${HELD_SUBMIT_REPORT_ID}`]: createMockTransaction(`trans_${HELD_SUBMIT_REPORT_ID}`, HELD_SUBMIT_REPORT_ID, heldOverride), - [`${ONYXKEYS.COLLECTION.TRANSACTION}trans_${HELD_APPROVE_REPORT_ID}`]: createMockTransaction(`trans_${HELD_APPROVE_REPORT_ID}`, HELD_APPROVE_REPORT_ID, heldOverride), - [`${ONYXKEYS.COLLECTION.TRANSACTION}trans_${HELD_PAY_REPORT_ID}`]: createMockTransaction(`trans_${HELD_PAY_REPORT_ID}`, HELD_PAY_REPORT_ID, heldOverride), - } as OnyxMultiSetInput); - - await waitForBatchedUpdates(); - }); - - it('excludes an all-held report from reportsToSubmit', async () => { - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - expect(todos?.reportsToSubmit.map((r) => r.reportID)).not.toContain(HELD_SUBMIT_REPORT_ID); - }); - - it('excludes an all-held report from reportsToApprove', async () => { - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - expect(todos?.reportsToApprove.map((r) => r.reportID)).not.toContain(HELD_APPROVE_REPORT_ID); - }); - - it('excludes an all-held report from reportsToPay', async () => { - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - expect(todos?.reportsToPay.map((r) => r.reportID)).not.toContain(HELD_PAY_REPORT_ID); - }); - }); - - describe('categorizes reports correctly', () => { - const SUBMIT_REPORT_IDS = ['submit_1', 'submit_2', 'submit_3', 'submit_4']; - const APPROVE_REPORT_IDS = ['approve_1', 'approve_2', 'approve_3']; - const PAY_REPORT_IDS = ['pay_1', 'pay_2']; - const EXPORT_REPORT_ID = 'export_1'; - const EXCLUDED_REPORT_IDS = ['excluded_1', 'excluded_2']; - - beforeEach(async () => { - // Create 4 reports that can be submitted (open, owned by current user, with transactions) - const reportsToSubmit = SUBMIT_REPORT_IDS.map((id) => - createMockReport(id, { - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - }), - ); - - // Create 3 reports that can be approved (submitted, current user is manager, with transactions) - const reportsToApprove = APPROVE_REPORT_IDS.map((id) => - createMockReport(id, { - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - managerID: CURRENT_USER_ACCOUNT_ID, - }), - ); - - // Create 2 reports that can be paid (approved, current user is admin/payer, with reimbursable transactions) - const reportsToPay = PAY_REPORT_IDS.map((id) => - createMockReport(id, { - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - managerID: CURRENT_USER_ACCOUNT_ID, - total: -100, - isWaitingOnBankAccount: false, - }), - ); - - // Create 1 report that can be exported: - // - Approved status - // - User is admin - // - Policy has a valid accounting connection with auto-sync disabled - // - Not waiting on bank account - const reportToExport = createMockReport(EXPORT_REPORT_ID, { - policyID: POLICY_WITH_CONNECTION_ID, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - isWaitingOnBankAccount: false, - }); - - // Create 2 reports that don't fit any condition: - // 1. A chat report (not expense type) - // 2. An expense report owned by another user that's not submitted (can't submit, approve, pay, or export) - const excludedReports = [ - createMockReport(EXCLUDED_REPORT_IDS.at(0) ?? '', { - type: CONST.REPORT.TYPE.CHAT, - }), - createMockReport(EXCLUDED_REPORT_IDS.at(1) ?? '', { - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - managerID: OTHER_USER_ACCOUNT_ID, - }), - ]; - - // Create main policy (for submit, approve, pay reports) - const policy = createMockPolicy(POLICY_ID, { - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - role: CONST.POLICY.ROLE.ADMIN, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - }); - - // Create policy with accounting connection (for export report) - const policyWithConnection = { - ...createMockPolicy(POLICY_WITH_CONNECTION_ID, { - role: CONST.POLICY.ROLE.ADMIN, - exporter: CURRENT_USER_EMAIL, - }), - connections: { - // QuickBooks Online connection with auto-sync disabled - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - lastSync: { - isConnected: true, - isSuccessful: true, - isAuthenticationError: false, - source: 'DIRECT', - }, - config: { - autoSync: { - jobID: 'job123', - enabled: false, // Auto-sync disabled so manual export is available - }, - export: { - exporter: CURRENT_USER_EMAIL, - }, - }, - }, - }, - } as Policy; - - const transactions: OnyxCollection = {}; - - for (const reportID of SUBMIT_REPORT_IDS) { - const transactionID = `trans_submit_${reportID}`; - transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = createMockTransaction(transactionID, reportID); - } - - for (const reportID of APPROVE_REPORT_IDS) { - const transactionID = `trans_approve_${reportID}`; - transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = createMockTransaction(transactionID, reportID); - } - - for (const reportID of PAY_REPORT_IDS) { - const transactionID = `trans_pay_${reportID}`; - transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = createMockTransaction(transactionID, reportID); - } - - const reports: OnyxCollection = {}; - for (const report of [...reportsToSubmit, ...reportsToApprove, ...reportsToPay, reportToExport, ...excludedReports]) { - reports[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; - } - - await Onyx.multiSet({ - [ONYXKEYS.SESSION]: { - email: CURRENT_USER_EMAIL, - accountID: CURRENT_USER_ACCOUNT_ID, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: policy, - [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_WITH_CONNECTION_ID}`]: policyWithConnection, - ...reports, - ...transactions, - } as OnyxMultiSetInput); - - await waitForBatchedUpdates(); - }); - - it('returns correct number of reports for each category', async () => { - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - expect(todos?.reportsToSubmit).toHaveLength(4); - expect(todos?.reportsToApprove).toHaveLength(3); - expect(todos?.reportsToPay).toHaveLength(2); - expect(todos?.reportsToExport).toHaveLength(1); - }); - - it('includes correct report IDs in each category', async () => { - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - const submitReportIDs = todos?.reportsToSubmit.map((r) => r.reportID) ?? []; - const approveReportIDs = todos?.reportsToApprove.map((r) => r.reportID) ?? []; - const payReportIDs = todos?.reportsToPay.map((r) => r.reportID) ?? []; - const exportReportIDs = todos?.reportsToExport.map((r) => r.reportID) ?? []; - - expect(submitReportIDs).toEqual(expect.arrayContaining(SUBMIT_REPORT_IDS)); - expect(approveReportIDs).toEqual(expect.arrayContaining(APPROVE_REPORT_IDS)); - expect(payReportIDs).toEqual(expect.arrayContaining(PAY_REPORT_IDS)); - expect(exportReportIDs).toContain(EXPORT_REPORT_ID); - }); - - it('excludes reports that do not match any category', async () => { - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - const allReportIDs = [ - ...(todos?.reportsToSubmit.map((r) => r.reportID) ?? []), - ...(todos?.reportsToApprove.map((r) => r.reportID) ?? []), - ...(todos?.reportsToPay.map((r) => r.reportID) ?? []), - ...(todos?.reportsToExport.map((r) => r.reportID) ?? []), - ]; - - expect(allReportIDs).not.toContain(EXCLUDED_REPORT_IDS.at(0)); - expect(allReportIDs).not.toContain(EXCLUDED_REPORT_IDS.at(1)); - }); - - it('builds transactionsByReportID mapping correctly', async () => { - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - expect(todos?.transactionsByReportID).toBeDefined(); - const firstSubmitReportID = SUBMIT_REPORT_IDS.at(0) ?? ''; - expect(todos?.transactionsByReportID[firstSubmitReportID]).toHaveLength(1); - expect(todos?.transactionsByReportID[firstSubmitReportID]?.at(0)?.transactionID).toBe(`trans_submit_${firstSubmitReportID}`); - expect(todos?.transactionsByReportID[APPROVE_REPORT_IDS.at(0) ?? '']).toHaveLength(1); - expect(todos?.transactionsByReportID[PAY_REPORT_IDS.at(0) ?? '']).toHaveLength(1); - }); - - it('handles reports with multiple transactions', async () => { - // Add a second transaction to one of the submit reports - const reportID = SUBMIT_REPORT_IDS.at(0) ?? ''; - const secondTransactionID = `trans_submit_${reportID}_2`; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransactionID}`, createMockTransaction(secondTransactionID, reportID)); - await waitForBatchedUpdates(); - - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - expect(todos?.transactionsByReportID[reportID]).toHaveLength(2); - expect(todos?.transactionsByReportID[reportID]?.map((t) => t.transactionID)).toEqual(expect.arrayContaining([`trans_submit_${reportID}`, secondTransactionID])); - }); - - it('handles reports without transactions', async () => { - const reportWithoutTransactions = createMockReport('no_transactions_report', { - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - }); - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportWithoutTransactions.reportID}`, reportWithoutTransactions); - await waitForBatchedUpdates(); - - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - // The report should still be categorized, but transactionsByReportID should be undefined - expect(todos?.transactionsByReportID[reportWithoutTransactions.reportID]).toBeUndefined(); - }); - - it('updates when report state changes', async () => { - // Start with a report that can be submitted - const reportID = SUBMIT_REPORT_IDS.at(0); - let todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - expect(todos?.reportsToSubmit.map((r) => r.reportID)).toContain(reportID); - - // Change the report to submitted state - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - }); - await waitForBatchedUpdates(); - - todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - // The report should no longer be in reportsToSubmit - expect(todos?.reportsToSubmit.map((r) => r.reportID)).not.toContain(reportID); - }); - - it('updates when transaction is added', async () => { - // Add a transaction to a report that previously had none - const reportID = 'new_report_with_transaction'; - const report = createMockReport(reportID, { - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - }); - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await waitForBatchedUpdates(); - - let todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - expect(todos?.transactionsByReportID[reportID] ?? []).toEqual([]); - - const transactionID = `trans_${reportID}`; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, createMockTransaction(transactionID, reportID)); - await waitForBatchedUpdates(); - - todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - expect(todos?.transactionsByReportID[reportID]).toHaveLength(1); - expect(todos?.transactionsByReportID[reportID]?.at(0)?.transactionID).toBe(transactionID); - }); - }); - - it('excludes export reports when user is connection-level exporter but not policy.exporter', async () => { - const EXPORT_POLICY_ID = 'policy_export_mismatch'; - const reportID = 'export_mismatch_report'; - - const report = createMockReport(reportID, { - policyID: EXPORT_POLICY_ID, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - isWaitingOnBankAccount: false, - }); - - const policy = { - ...createMockPolicy(EXPORT_POLICY_ID, { - role: CONST.POLICY.ROLE.ADMIN, - exporter: 'someone-else@mail.com', - }), - connections: { - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - lastSync: { - isConnected: true, - isSuccessful: true, - isAuthenticationError: false, - source: 'DIRECT', - }, - config: { - autoSync: { - jobID: 'job123', - enabled: false, - }, - export: { - exporter: CURRENT_USER_EMAIL, - }, - }, - }, - }, - } as Policy; - - await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${EXPORT_POLICY_ID}`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - - await waitForBatchedUpdates(); - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - expect(todos?.reportsToExport).toHaveLength(0); - }); - - describe('uses primary login from personalDetailsList', () => { - const SECONDARY_LOGIN = '+15555551234'; // Phone number as secondary login - const PRIMARY_LOGIN = 'primary@example.com'; // Primary email - - const createMockAchAccount = (reimburserLogin: string): ACHAccount => ({ - reimburser: reimburserLogin, - bankAccountID: 1, - accountNumber: '1234567890', - routingNumber: '1234567890', - addressName: 'Test Address', - bankName: 'Test Bank', - }); - - const createPayableReport = (): Report => - createMockReport('pay_report', { - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - ownerAccountID: OTHER_USER_ACCOUNT_ID, - managerID: CURRENT_USER_ACCOUNT_ID, - total: -100, - isWaitingOnBankAccount: false, - }); - - it('uses primary login from personalDetailsList instead of session email for role checks', async () => { - const policy = createMockPolicy(POLICY_ID, { - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - achAccount: createMockAchAccount(PRIMARY_LOGIN), - }); - - const payReport = createPayableReport(); - const transaction = createMockTransaction('trans_pay', 'pay_report'); - - await Onyx.multiSet({ - [ONYXKEYS.SESSION]: { - email: SECONDARY_LOGIN, // User signed in with secondary login (phone) - accountID: CURRENT_USER_ACCOUNT_ID, - }, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: { - [CURRENT_USER_ACCOUNT_ID]: { - accountID: CURRENT_USER_ACCOUNT_ID, - login: PRIMARY_LOGIN, // Primary login stored in personal details - displayName: 'Test User', - }, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: policy, - [`${ONYXKEYS.COLLECTION.REPORT}${payReport.reportID}`]: payReport, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, - } as OnyxMultiSetInput); - - await waitForBatchedUpdates(); - - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - // The report should appear in reportsToPay because primary login matches reimburser - expect(todos?.reportsToPay).toHaveLength(1); - expect(todos?.reportsToPay.at(0)?.reportID).toBe('pay_report'); - }); - - it('falls back to session email when personalDetailsList is not available', async () => { - const policy = createMockPolicy(POLICY_ID, { - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - achAccount: createMockAchAccount(CURRENT_USER_EMAIL), - }); - - const payReport = createPayableReport(); - const transaction = createMockTransaction('trans_pay', 'pay_report'); - - await Onyx.multiSet({ - [ONYXKEYS.SESSION]: { - email: CURRENT_USER_EMAIL, - accountID: CURRENT_USER_ACCOUNT_ID, - }, - // No PERSONAL_DETAILS_LIST set - should fall back to session email - [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: policy, - [`${ONYXKEYS.COLLECTION.REPORT}${payReport.reportID}`]: payReport, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, - } as OnyxMultiSetInput); - - await waitForBatchedUpdates(); - - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - // The report should still appear in reportsToPay using session email fallback - expect(todos?.reportsToPay).toHaveLength(1); - expect(todos?.reportsToPay.at(0)?.reportID).toBe('pay_report'); - }); - - it('does not show pay todo when secondary login does not match reimburser and personalDetailsList is missing', async () => { - const policy = createMockPolicy(POLICY_ID, { - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - achAccount: createMockAchAccount(PRIMARY_LOGIN), - }); - - const payReport = createPayableReport(); - const transaction = createMockTransaction('trans_pay', 'pay_report'); - - await Onyx.multiSet({ - [ONYXKEYS.SESSION]: { - email: SECONDARY_LOGIN, // Secondary login that doesn't match reimburser - accountID: CURRENT_USER_ACCOUNT_ID, - }, - // No PERSONAL_DETAILS_LIST - will fall back to session email which doesn't match - [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: policy, - [`${ONYXKEYS.COLLECTION.REPORT}${payReport.reportID}`]: payReport, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, - } as OnyxMultiSetInput); - - await waitForBatchedUpdates(); - - const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS); - - // The report should NOT appear in reportsToPay because secondary login doesn't match - expect(todos?.reportsToPay).toHaveLength(0); - }); - }); - }); }); diff --git a/tests/unit/useTodoCountsTest.tsx b/tests/unit/useTodoCountsTest.tsx new file mode 100644 index 000000000000..c10a8b0171c4 --- /dev/null +++ b/tests/unit/useTodoCountsTest.tsx @@ -0,0 +1,470 @@ +import {act, renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useTodoCounts from '@hooks/useTodoCounts'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type {ACHAccount} from '@src/types/onyx/Policy'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'tester@mail.com'; +const OTHER_USER_ACCOUNT_ID = 2; + +const POLICY_ID = 'policy123'; +const POLICY_WITH_CONNECTION_ID = 'policy_with_connection'; + +// Helper functions that mirror the precise control the removed TODOS derived-value tests relied on. +const createMockReport = (reportID: string, overrides: Partial = {}): Report => ({ + reportID, + chatReportID: `chat_${reportID}`, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: OTHER_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + parentReportID: '123', + parentReportActionID: '456', + reportName: 'Test Report', + currency: 'USD', + isOwnPolicyExpenseChat: false, + isPinned: false, + isWaitingOnBankAccount: false, + ...overrides, +}); + +const createMockPolicy = (policyID: string, overrides: Partial = {}): Policy => ({ + id: policyID, + name: 'Test Policy', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + owner: CURRENT_USER_EMAIL, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + ...overrides, +}); + +// Builds an admin policy with a QBO connection whose auto-sync is disabled, so a report on it is manually exportable. +// The `Connections` type requires an entry for every supported integration, so a single-integration literal can only +// be supplied through an assertion - isolated here so it is the one such cast in this file. +const createPolicyWithQBOConnection = (policyID: string, {policyExporter, connectionExporter}: {policyExporter: string; connectionExporter: string}): Policy => + ({ + ...createMockPolicy(policyID, {role: CONST.POLICY.ROLE.ADMIN, exporter: policyExporter}), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + isConnected: true, + isSuccessful: true, + isAuthenticationError: false, + source: 'DIRECT', + }, + config: { + autoSync: { + jobID: 'job123', + enabled: false, // Auto-sync disabled so manual export is available + }, + export: { + exporter: connectionExporter, + }, + }, + }, + }, + }) as unknown as Policy; + +const createMockTransaction = (transactionID: string, reportID: string, overrides: Partial = {}): Transaction => + ({ + transactionID, + reportID, + amount: 100, + modifiedAmount: 0, + reimbursable: true, + status: CONST.TRANSACTION.STATUS.POSTED, + currency: 'USD', + merchant: 'Test Merchant', + created: '2024-01-01', + ...overrides, + }) as Transaction; + +const setReports = (reports: Report[]) => Promise.all(reports.map((report) => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report))); +const setTransactions = (transactions: Transaction[]) => + Promise.all(transactions.map((transaction) => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction))); +const setPolicies = (policies: Policy[]) => Promise.all(policies.map((policy) => Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy))); + +const renderTodoCounts = async (enabled = true) => { + const hook = renderHook(({isEnabled}: {isEnabled: boolean}) => useTodoCounts(isEnabled), {initialProps: {isEnabled: enabled}}); + await act(async () => { + await waitForBatchedUpdates(); + }); + return hook; +}; + +describe('useTodoCounts', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('returns zero counts and no single report IDs when dependencies are not set', async () => { + const {result} = await renderTodoCounts(); + + expect(result.current.counts).toEqual({ + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: 0, + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: 0, + [CONST.SEARCH.SEARCH_KEYS.PAY]: 0, + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: 0, + }); + expect(result.current.singleReportIDs).toEqual({ + [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined, + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: undefined, + [CONST.SEARCH.SEARCH_KEYS.PAY]: undefined, + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: undefined, + }); + }); + + describe('excludes reports with all expenses on hold', () => { + const HELD_SUBMIT_REPORT_ID = 'held_submit_1'; + const HELD_APPROVE_REPORT_ID = 'held_approve_1'; + const HELD_PAY_REPORT_ID = 'held_pay_1'; + + beforeEach(async () => { + const submitReport = createMockReport(HELD_SUBMIT_REPORT_ID, { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + }); + const approveReport = createMockReport(HELD_APPROVE_REPORT_ID, { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + }); + const payReport = createMockReport(HELD_PAY_REPORT_ID, { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + total: -100, + }); + + const policy = createMockPolicy(POLICY_ID, { + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + }); + + const heldOverride: Partial = {comment: {hold: 'HOLD_ACTION_ID'}}; + + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await setPolicies([policy]); + await setReports([submitReport, approveReport, payReport]); + await setTransactions([ + createMockTransaction(`trans_${HELD_SUBMIT_REPORT_ID}`, HELD_SUBMIT_REPORT_ID, heldOverride), + createMockTransaction(`trans_${HELD_APPROVE_REPORT_ID}`, HELD_APPROVE_REPORT_ID, heldOverride), + createMockTransaction(`trans_${HELD_PAY_REPORT_ID}`, HELD_PAY_REPORT_ID, heldOverride), + ]); + await waitForBatchedUpdates(); + }); + + it('does not count all-held reports in any bucket', async () => { + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe(0); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.APPROVE]).toBe(0); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe(0); + }); + }); + + describe('categorizes reports correctly', () => { + const SUBMIT_REPORT_IDS = ['submit_1', 'submit_2', 'submit_3', 'submit_4']; + const APPROVE_REPORT_IDS = ['approve_1', 'approve_2', 'approve_3']; + const PAY_REPORT_IDS = ['pay_1', 'pay_2']; + const EXPORT_REPORT_ID = 'export_1'; + const EXCLUDED_REPORT_IDS = ['excluded_1', 'excluded_2']; + + beforeEach(async () => { + // 4 reports that can be submitted (open, owned by current user, with transactions) + const reportsToSubmit = SUBMIT_REPORT_IDS.map((id) => + createMockReport(id, { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + }), + ); + + // 3 reports that can be approved (submitted, current user is manager, with transactions) + const reportsToApprove = APPROVE_REPORT_IDS.map((id) => + createMockReport(id, { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + }), + ); + + // 2 reports that can be paid (approved, current user is admin/payer, with reimbursable transactions) + const reportsToPay = PAY_REPORT_IDS.map((id) => + createMockReport(id, { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + total: -100, + isWaitingOnBankAccount: false, + }), + ); + + // 1 report that can be exported (approved, user is admin, valid connection with auto-sync disabled) + const reportToExport = createMockReport(EXPORT_REPORT_ID, { + policyID: POLICY_WITH_CONNECTION_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + isWaitingOnBankAccount: false, + }); + + // 2 reports that don't fit any condition + const excludedReports = [ + createMockReport(EXCLUDED_REPORT_IDS.at(0) ?? '', { + type: CONST.REPORT.TYPE.CHAT, + }), + createMockReport(EXCLUDED_REPORT_IDS.at(1) ?? '', { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATE_NUM.OPEN, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: OTHER_USER_ACCOUNT_ID, + }), + ]; + + const policy = createMockPolicy(POLICY_ID, { + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + }); + const policyWithConnection = createPolicyWithQBOConnection(POLICY_WITH_CONNECTION_ID, {policyExporter: CURRENT_USER_EMAIL, connectionExporter: CURRENT_USER_EMAIL}); + + const transactions: Transaction[] = [ + ...SUBMIT_REPORT_IDS.map((reportID) => createMockTransaction(`trans_submit_${reportID}`, reportID)), + ...APPROVE_REPORT_IDS.map((reportID) => createMockTransaction(`trans_approve_${reportID}`, reportID)), + ...PAY_REPORT_IDS.map((reportID) => createMockTransaction(`trans_pay_${reportID}`, reportID)), + ]; + + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await setPolicies([policy, policyWithConnection]); + await setReports([...reportsToSubmit, ...reportsToApprove, ...reportsToPay, reportToExport, ...excludedReports]); + await setTransactions(transactions); + await waitForBatchedUpdates(); + }); + + it('returns the correct count for each category', async () => { + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe(4); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.APPROVE]).toBe(3); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe(2); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.EXPORT]).toBe(1); + }); + + it('exposes the report ID only for buckets that contain exactly one report', async () => { + const {result} = await renderTodoCounts(); + + // Only the export bucket has a single report, so only it surfaces an ID. + expect(result.current.singleReportIDs[CONST.SEARCH.SEARCH_KEYS.EXPORT]).toBe(EXPORT_REPORT_ID); + expect(result.current.singleReportIDs[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBeUndefined(); + expect(result.current.singleReportIDs[CONST.SEARCH.SEARCH_KEYS.APPROVE]).toBeUndefined(); + expect(result.current.singleReportIDs[CONST.SEARCH.SEARCH_KEYS.PAY]).toBeUndefined(); + }); + + it('updates the submit count when a report state changes', async () => { + const {result} = await renderTodoCounts(); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe(4); + + // Move one submittable report to the submitted state - it should drop out of the submit bucket. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${SUBMIT_REPORT_IDS.at(0)}`, { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + await waitForBatchedUpdates(); + }); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe(3); + }); + }); + + it('does not count export reports when the user is a connection-level exporter but not policy.exporter', async () => { + const EXPORT_POLICY_ID = 'policy_export_mismatch'; + const reportID = 'export_mismatch_report'; + + const report = createMockReport(reportID, { + policyID: EXPORT_POLICY_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + isWaitingOnBankAccount: false, + }); + // policy.exporter is someone else, even though the connection-level exporter is the current user. + const policy = createPolicyWithQBOConnection(EXPORT_POLICY_ID, {policyExporter: 'someone-else@mail.com', connectionExporter: CURRENT_USER_EMAIL}); + + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await setPolicies([policy]); + await setReports([report]); + await waitForBatchedUpdates(); + + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.EXPORT]).toBe(0); + }); + + describe('uses primary login from personalDetailsList', () => { + const SECONDARY_LOGIN = '+15555551234'; // Phone number as secondary login + const PRIMARY_LOGIN = 'primary@example.com'; // Primary email + + const createMockAchAccount = (reimburserLogin: string): ACHAccount => ({ + reimburser: reimburserLogin, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '1234567890', + addressName: 'Test Address', + bankName: 'Test Bank', + }); + + const createPayableReport = (): Report => + createMockReport('pay_report', { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + total: -100, + isWaitingOnBankAccount: false, + }); + + it('uses the primary login from personalDetailsList instead of the session email for role checks', async () => { + const policy = createMockPolicy(POLICY_ID, { + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + achAccount: createMockAchAccount(PRIMARY_LOGIN), + }); + + const payReport = createPayableReport(); + + await Onyx.set(ONYXKEYS.SESSION, {email: SECONDARY_LOGIN, accountID: CURRENT_USER_ACCOUNT_ID}); // User signed in with secondary login (phone) + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [CURRENT_USER_ACCOUNT_ID]: { + accountID: CURRENT_USER_ACCOUNT_ID, + login: PRIMARY_LOGIN, // Primary login stored in personal details + displayName: 'Test User', + }, + }); + await setPolicies([policy]); + await setReports([payReport]); + await setTransactions([createMockTransaction('trans_pay', 'pay_report')]); + await waitForBatchedUpdates(); + + const {result} = await renderTodoCounts(); + + // The report should count toward pay because the primary login matches the reimburser. + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe(1); + expect(result.current.singleReportIDs[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe('pay_report'); + }); + + it('falls back to the session email when personalDetailsList is not available', async () => { + const policy = createMockPolicy(POLICY_ID, { + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + achAccount: createMockAchAccount(CURRENT_USER_EMAIL), + }); + + const payReport = createPayableReport(); + + // No PERSONAL_DETAILS_LIST set - should fall back to session email + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await setPolicies([policy]); + await setReports([payReport]); + await setTransactions([createMockTransaction('trans_pay', 'pay_report')]); + await waitForBatchedUpdates(); + + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe(1); + expect(result.current.singleReportIDs[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe('pay_report'); + }); + + it('does not count a pay todo when the secondary login does not match the reimburser and personalDetailsList is missing', async () => { + const policy = createMockPolicy(POLICY_ID, { + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + achAccount: createMockAchAccount(PRIMARY_LOGIN), + }); + + const payReport = createPayableReport(); + + // No PERSONAL_DETAILS_LIST - falls back to session email (secondary login) which doesn't match the reimburser + await Onyx.set(ONYXKEYS.SESSION, {email: SECONDARY_LOGIN, accountID: CURRENT_USER_ACCOUNT_ID}); + await setPolicies([policy]); + await setReports([payReport]); + await setTransactions([createMockTransaction('trans_pay', 'pay_report')]); + await waitForBatchedUpdates(); + + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe(0); + }); + }); + + describe('freezes when disabled', () => { + const SUBMIT_REPORT_ID = 'freeze_submit_1'; + const SECOND_SUBMIT_REPORT_ID = 'freeze_submit_2'; + + const seedSubmittableReport = (reportID: string) => { + const report = createMockReport(reportID, { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATE_NUM.OPEN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + }); + return Promise.all([setReports([report]), setTransactions([createMockTransaction(`trans_${reportID}`, reportID)])]); + }; + + beforeEach(async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await setPolicies([createMockPolicy(POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, ownerAccountID: CURRENT_USER_ACCOUNT_ID})]); + await seedSubmittableReport(SUBMIT_REPORT_ID); + await waitForBatchedUpdates(); + }); + + it('does not recompute while disabled, then recomputes once re-enabled', async () => { + const {result, rerender} = renderHook(({isEnabled}: {isEnabled: boolean}) => useTodoCounts(isEnabled), {initialProps: {isEnabled: true}}); + await act(async () => { + await waitForBatchedUpdates(); + }); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe(1); + + // Freeze the hook. + rerender({isEnabled: false}); + await act(async () => { + await waitForBatchedUpdates(); + }); + + // Add another submittable report while frozen - the count must stay at the captured value. + await act(async () => { + await seedSubmittableReport(SECOND_SUBMIT_REPORT_ID); + await waitForBatchedUpdates(); + }); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe(1); + + // Re-enable - it recomputes and picks up the report added while frozen. + rerender({isEnabled: true}); + await act(async () => { + await waitForBatchedUpdates(); + }); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe(2); + }); + }); +}); From 9aa3af611429159bce3e546d42eb4bd0a6f6d3a6 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 26 Jun 2026 16:02:09 +0200 Subject: [PATCH 10/16] code cleanup --- src/hooks/useTodoCounts.ts | 26 +++++++------------ ...DynamicNewReportWorkspaceSelectionPage.tsx | 13 ++-------- src/pages/Search/SearchTypeMenuWide.tsx | 2 ++ 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/src/hooks/useTodoCounts.ts b/src/hooks/useTodoCounts.ts index 6f75dc177b11..3d724c58f052 100644 --- a/src/hooks/useTodoCounts.ts +++ b/src/hooks/useTodoCounts.ts @@ -20,6 +20,8 @@ type TodoSingleReportIDs = { [CONST.SEARCH.SEARCH_KEYS.EXPORT]: string | undefined; }; +const TODO_KEYS = [CONST.SEARCH.SEARCH_KEYS.SUBMIT, CONST.SEARCH.SEARCH_KEYS.APPROVE, CONST.SEARCH.SEARCH_KEYS.PAY, CONST.SEARCH.SEARCH_KEYS.EXPORT] as const; + /** * Computes live to-do report counts and, for each bucket that contains exactly one report, that report's ID. * Runs the to-do classification on demand from live Onyx data, only while a consumer is mounted, replacing the @@ -40,11 +42,11 @@ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: To const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); // Holds the most recent result so a frozen (inactive) consumer can keep returning it without recomputing. - const [frozen, setFrozen] = useState<{signature: string; value: {counts: TodoCounts; singleReportIDs: TodoSingleReportIDs}} | null>(null); + const [frozen, setFrozen] = useState<{counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} | null>(null); // While frozen, reuse the last captured result and skip the expensive classification below. if (!enabled && frozen) { - return frozen.value; + return frozen; } const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; @@ -78,21 +80,11 @@ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: To const value = {counts, singleReportIDs}; - // Capture the latest result so it can be returned verbatim once the consumer freezes. Guarded by a content - // signature so we only re-render when the counts/IDs actually change (avoids a setState-during-render loop). - const signature = [ - counts[CONST.SEARCH.SEARCH_KEYS.SUBMIT], - counts[CONST.SEARCH.SEARCH_KEYS.APPROVE], - counts[CONST.SEARCH.SEARCH_KEYS.PAY], - counts[CONST.SEARCH.SEARCH_KEYS.EXPORT], - singleReportIDs[CONST.SEARCH.SEARCH_KEYS.SUBMIT], - singleReportIDs[CONST.SEARCH.SEARCH_KEYS.APPROVE], - singleReportIDs[CONST.SEARCH.SEARCH_KEYS.PAY], - singleReportIDs[CONST.SEARCH.SEARCH_KEYS.EXPORT], - ].join('|'); - - if (frozen?.signature !== signature) { - setFrozen({signature, value}); + // Capture the latest result so it can be returned verbatim once the consumer freezes. Only re-store when a + // count or single-report ID actually changes, so the setState-during-render can't loop. + const hasChanged = !frozen || TODO_KEYS.some((key) => frozen.counts[key] !== counts[key] || frozen.singleReportIDs[key] !== singleReportIDs[key]); + if (hasChanged) { + setFrozen(value); } return value; diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index a64bd9cee547..96be359f0c26 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -31,6 +31,7 @@ import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; import {canSubmitPerDiemExpenseFromWorkspace, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {buildTransactionsByReportID} from '@libs/TodosUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; import isRHPOnSearchMoneyRequestReportPage from '@navigation/helpers/isRHPOnSearchMoneyRequestReportPage'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -40,7 +41,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type WorkspaceListItem = { @@ -89,16 +89,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const transactionsByReportID: Record = {}; - for (const transaction of Object.values(allTransactions ?? {})) { - if (!transaction?.reportID) { - continue; - } - if (!transactionsByReportID[transaction.reportID]) { - transactionsByReportID[transaction.reportID] = []; - } - transactionsByReportID[transaction.reportID].push(transaction); - } + const transactionsByReportID = buildTransactionsByReportID(allTransactions); const policiesWithEmptyReportsForAccountSelector = policyIDsWithEmptyReportsSelector(accountID, transactionsByReportID, !!hasDismissedEmptyReportsConfirmation); const [policiesWithEmptyReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policiesWithEmptyReportsForAccountSelector}); diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index 49e38db51844..d8f459a599c2 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -129,6 +129,8 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { const {typeMenuSections, activeItemIndex} = useSearchTypeMenuSections({hash, similarSearchHash, sortBy, sortOrder, type}); const {isVisuallyCollapsed} = useSearchSidebarCollapse(); const [isSearchDataLoaded, isSearchDataLoadedResult] = useOnyx(ONYXKEYS.IS_SEARCH_PAGE_DATA_LOADED); + // Intentionally left enabled (no focus freeze): the wide menu renders in the search navigator's ExtraContent + // slot, where useIsFocused() does not track visibility, so freezing on it would be unreliable. const {counts: reportCounts} = useTodoCounts(); const route = useRoute(); From 76b5ea8ce9cf4f2bb7f3610bb1d70884554de14b Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 29 Jun 2026 09:01:25 +0200 Subject: [PATCH 11/16] fix knip --- src/hooks/useTodoCounts.ts | 2 +- src/hooks/useTodoSearchResults.ts | 1 - src/libs/TodosUtils.ts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/hooks/useTodoCounts.ts b/src/hooks/useTodoCounts.ts index 3d724c58f052..f263f4206bfe 100644 --- a/src/hooks/useTodoCounts.ts +++ b/src/hooks/useTodoCounts.ts @@ -91,4 +91,4 @@ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: To } export default useTodoCounts; -export type {TodoCounts, TodoSingleReportIDs}; +export type {TodoCounts}; diff --git a/src/hooks/useTodoSearchResults.ts b/src/hooks/useTodoSearchResults.ts index c6122eb8df6f..06e5d14edcd2 100644 --- a/src/hooks/useTodoSearchResults.ts +++ b/src/hooks/useTodoSearchResults.ts @@ -161,4 +161,3 @@ function useTodoSearchResults(searchKey: SearchKey | undefined): {data: TodoSear } export default useTodoSearchResults; -export type {TodoMetadata, TodoSearchResultsData}; diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index f1e7e3b85c9a..fe6342e582c0 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -174,4 +174,3 @@ function getTodoReportsForSearchKey( export default createTodosReportsAndTransactions; export {buildTransactionsByReportID, getTodoReportsForSearchKey}; -export type {CreateTodosReportsAndTransactionsParams}; From d045b14f1f364b60445ccc9a9b64a898ccf7f00d Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 29 Jun 2026 12:42:37 +0200 Subject: [PATCH 12/16] Extract shared reportMatchesTodoBucket helper to dedupe todo classification --- src/libs/TodosUtils.ts | 134 +++++++++++++++++++++++------------------ 1 file changed, 77 insertions(+), 57 deletions(-) diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index fe6342e582c0..9fc0d88ca2fc 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -35,6 +35,56 @@ function buildTransactionsByReportID(allTransactions: OnyxCollection; + reportNameValuePair: OnyxEntry; + reportTransactions: Transaction[]; + reportMetadata: OnyxEntry; + allReportActions: OnyxCollection; + allExpensesHeld: boolean; + bankAccountList: OnyxEntry; + currentUserAccountID: number; + login: string; +}; + +/** + * Single source of truth for whether a report belongs in a given to-do bucket. Both the all-buckets path + * (`createTodosReportsAndTransactions`) and the single-bucket path (`getTodoReportsForSearchKey`) call this so + * the classification rules can't drift between them. + */ +function reportMatchesTodoBucket( + searchKey: SearchKey, + report: Report, + {policy, reportNameValuePair, reportTransactions, reportMetadata, allReportActions, allExpensesHeld, bankAccountList, currentUserAccountID, login}: TodoBucketContext, +): boolean { + switch (searchKey) { + case CONST.SEARCH.SEARCH_KEYS.SUBMIT: + return isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld; + case CONST.SEARCH.SEARCH_KEYS.APPROVE: + return isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld; + case CONST.SEARCH.SEARCH_KEYS.PAY: + return ( + isPrimaryPayAction({ + report, + reportTransactions, + currentUserAccountID, + currentUserLogin: login, + bankAccountList, + policy, + reportNameValuePairs: reportNameValuePair, + }) && + !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) && + !allExpensesHeld + ); + case CONST.SEARCH.SEARCH_KEYS.EXPORT: { + const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); + return isExportAction(report, login, policy, reportActions) && policy?.exporter === login; + } + default: + return false; + } +} + /** * Classifies every expense report into the four to-do buckets (submit/approve/pay/export) and indexes * transactions by report ID. Used by the TODOS derived value and the on-demand to-do count hook. @@ -67,34 +117,28 @@ function createTodosReportsAndTransactions({ if (!report?.reportID || report.type !== CONST.REPORT.TYPE.EXPENSE) { continue; } - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - const reportNameValuePair = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`]; - const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); const reportTransactions = transactionsByReportID[report.reportID] ?? []; - const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; - const allExpensesHeld = hasOnlyHeldExpenses(reportTransactions); - if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld) { + const context: TodoBucketContext = { + policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`], + reportNameValuePair: allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`], + reportTransactions, + reportMetadata: allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`], + allReportActions, + allExpensesHeld: hasOnlyHeldExpenses(reportTransactions), + bankAccountList, + currentUserAccountID, + login, + }; + if (reportMatchesTodoBucket(CONST.SEARCH.SEARCH_KEYS.SUBMIT, report, context)) { reportsToSubmit.push(report); } - if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld) { + if (reportMatchesTodoBucket(CONST.SEARCH.SEARCH_KEYS.APPROVE, report, context)) { reportsToApprove.push(report); } - if ( - isPrimaryPayAction({ - report, - reportTransactions, - currentUserAccountID, - currentUserLogin: login, - bankAccountList, - policy, - reportNameValuePairs: reportNameValuePair, - }) && - !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) && - !allExpensesHeld - ) { + if (reportMatchesTodoBucket(CONST.SEARCH.SEARCH_KEYS.PAY, report, context)) { reportsToPay.push(report); } - if (isExportAction(report, login, policy, reportActions) && policy?.exporter === login) { + if (reportMatchesTodoBucket(CONST.SEARCH.SEARCH_KEYS.EXPORT, report, context)) { reportsToExport.push(report); } } @@ -127,44 +171,20 @@ function getTodoReportsForSearchKey( if (!report?.reportID || report.type !== CONST.REPORT.TYPE.EXPENSE) { continue; } - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - const reportNameValuePair = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`]; const reportTransactions = transactionsByReportID[report.reportID] ?? []; - const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; - const allExpensesHeld = hasOnlyHeldExpenses(reportTransactions); - - let matches = false; - switch (searchKey) { - case CONST.SEARCH.SEARCH_KEYS.SUBMIT: - matches = isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld; - break; - case CONST.SEARCH.SEARCH_KEYS.APPROVE: - matches = isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld; - break; - case CONST.SEARCH.SEARCH_KEYS.PAY: - matches = - isPrimaryPayAction({ - report, - reportTransactions, - currentUserAccountID, - currentUserLogin: login, - bankAccountList, - policy, - reportNameValuePairs: reportNameValuePair, - }) && - !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) && - !allExpensesHeld; - break; - case CONST.SEARCH.SEARCH_KEYS.EXPORT: { - const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); - matches = isExportAction(report, login, policy, reportActions) && policy?.exporter === login; - break; - } - default: - matches = false; - } + const context: TodoBucketContext = { + policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`], + reportNameValuePair: allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`], + reportTransactions, + reportMetadata: allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`], + allReportActions, + allExpensesHeld: hasOnlyHeldExpenses(reportTransactions), + bankAccountList, + currentUserAccountID, + login, + }; - if (matches) { + if (reportMatchesTodoBucket(searchKey, report, context)) { reports.push(report); } } From a9d81bf667d5cdffdb80eb71365de8599b89e9f5 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 29 Jun 2026 12:43:04 +0200 Subject: [PATCH 13/16] Treat empty todo search bucket as no results --- src/hooks/useTodoSearchResults.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/useTodoSearchResults.ts b/src/hooks/useTodoSearchResults.ts index 06e5d14edcd2..d6fd55bf1570 100644 --- a/src/hooks/useTodoSearchResults.ts +++ b/src/hooks/useTodoSearchResults.ts @@ -109,7 +109,10 @@ function buildSearchResultsData( } } - if (personalDetails) { + // Only attach personal details once we have at least one matching report. Otherwise an empty bucket would still + // produce a non-empty `data` object, which SearchResultsProvider reads as "has results" and shows the wrong + // empty state. + if (personalDetails && reports.length > 0) { // PersonalDetailsList allows null values whereas the snapshot type does not; merge via Object.assign to keep // the live data faithful (including any null entries) without an unsafe narrowing assertion. Object.assign(data, {[ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails}); From 9c9c1ba70d4b0ef4744cf9a7abe10079379b1e5f Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 29 Jun 2026 14:41:41 +0200 Subject: [PATCH 14/16] remove unused EMPTY_TODOS_REPORT_COUNTS --- Mobile-Expensify | 2 +- src/CONST/index.ts | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index f1d5ea73a24e..e63db2de9137 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit f1d5ea73a24e01b8c4346e8122ceb48bbca7489c +Subproject commit e63db2de91371835c1875f5eca87a78bebe3e7bd diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bf6ceaa2f719..10fdcea2c98e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -32,12 +32,6 @@ const EMPTY_ARRAY = Object.freeze([]); const EMPTY_OBJECT = Object.freeze({}); // Shared immutable map used in hot paths that only read from the instance. const EMPTY_MAP = new Map(); -const EMPTY_TODOS_REPORT_COUNTS = Object.freeze({ - submit: 0, - approve: 0, - pay: 0, - export: 0, -}); // Using 28 days to align with OldDot and because all months are guaranteed to be at least 28 days. const MONTH_DAYS = Object.freeze([...Array(28).keys()].map((i) => i + 1)); @@ -1241,7 +1235,6 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, EMPTY_MAP, - EMPTY_TODOS_REPORT_COUNTS, DEFAULT_NUMBER_ID, DEFAULT_MISSING_ID, DEFAULT_COUNTRY_CODE, From d795a8990b8a30383847b55e6de42a8efeda5f29 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 29 Jun 2026 15:13:15 +0200 Subject: [PATCH 15/16] add todos docs and tests --- src/libs/TodosUtils.ts | 38 +++++ tests/unit/TodosUtilsTest.ts | 322 +++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 tests/unit/TodosUtilsTest.ts diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index 2fc520faaeb1..f5bad83affe2 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -8,15 +8,34 @@ import {hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions} from './ReportU import type {SearchKey} from './SearchUIUtils'; type CreateTodosReportsAndTransactionsParams = { + /** Every report, keyed by report Onyx key - iterated to find the expense reports that belong in a to-do bucket */ allReports: OnyxCollection; + + /** Every transaction, keyed by transaction Onyx key - bucketed per report so each report's expenses are known */ allTransactions: OnyxCollection; + + /** Every policy, keyed by policy Onyx key - a report's policy drives its submit/approve/pay/export eligibility */ allPolicies: OnyxCollection; + + /** Every report name-value pair, keyed by report Onyx key - looked up by `chatReportID` for the workflow checks */ allReportNameValuePairs: OnyxCollection; + + /** Every report's actions, keyed by report Onyx key - used by the export predicate to detect exported reports */ allReportActions: OnyxCollection; + + /** Every report's metadata, keyed by report Onyx key - feeds the submit/approve predicates */ allReportMetadata: OnyxCollection; + + /** All personal details - used to resolve a report owner's login from their account ID for the submit predicate */ personalDetailsList: OnyxEntry; + + /** The current user's bank accounts - the pay predicate needs them to know whether the user can pay */ bankAccountList: OnyxEntry; + + /** The current user's account ID - identifies which reports the user owns/manages */ currentUserAccountID: number; + + /** The current user's primary login - matched against policy roles (e.g. exporter, reimburser) */ login: string; }; @@ -38,15 +57,34 @@ function buildTransactionsByReportID(allTransactions: OnyxCollection; + + /** The report's name-value pair (looked up by `chatReportID`) - feeds the submit/pay workflow checks */ reportNameValuePair: OnyxEntry; + + /** The report's transactions - inspected for amount, reimbursability, and hold status */ reportTransactions: Transaction[]; + + /** The report's metadata - feeds the submit/approve predicates */ reportMetadata: OnyxEntry; + + /** Every report's actions - the export predicate reads the actions for this report from it */ allReportActions: OnyxCollection; + + /** Whether every transaction on the report is on hold - precomputed once so held reports are excluded from submit/approve/pay */ allExpensesHeld: boolean; + + /** The report owner's login, resolved from `ownerAccountID` - the submit predicate matches it against the submitter */ ownerLogin: string | undefined; + + /** The current user's bank accounts - the pay predicate needs them to know whether the user can pay */ bankAccountList: OnyxEntry; + + /** The current user's account ID - identifies whether the user owns/manages this report */ currentUserAccountID: number; + + /** The current user's primary login - matched against policy roles (e.g. exporter, reimburser) */ login: string; }; diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts new file mode 100644 index 000000000000..0f729b6f5828 --- /dev/null +++ b/tests/unit/TodosUtilsTest.ts @@ -0,0 +1,322 @@ +import Onyx from 'react-native-onyx'; +import createTodosReportsAndTransactions, {buildTransactionsByReportID, getTodoReportsForSearchKey} from '@libs/TodosUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'tester@mail.com'; +const OTHER_USER_ACCOUNT_ID = 2; + +const POLICY_ID = 'policy123'; +const POLICY_WITH_CONNECTION_ID = 'policy_with_connection'; + +const createMockReport = (reportID: string, overrides: Partial = {}): Report => ({ + reportID, + chatReportID: `chat_${reportID}`, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: OTHER_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + parentReportID: '123', + parentReportActionID: '456', + reportName: 'Test Report', + currency: 'USD', + isOwnPolicyExpenseChat: false, + isPinned: false, + isWaitingOnBankAccount: false, + ...overrides, +}); + +const createMockPolicy = (policyID: string, overrides: Partial = {}): Policy => ({ + id: policyID, + name: 'Test Policy', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + owner: CURRENT_USER_EMAIL, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + ...overrides, +}); + +// Admin policy with a QBO connection whose auto-sync is disabled, so a report on it is manually exportable. +// The `Connections` type requires an entry for every supported integration, so a single-integration literal can +// only be supplied through an assertion - isolated here so it is the one such cast in this file. +const createPolicyWithQBOConnection = (policyID: string, {policyExporter, connectionExporter}: {policyExporter: string; connectionExporter: string}): Policy => + ({ + ...createMockPolicy(policyID, {role: CONST.POLICY.ROLE.ADMIN, exporter: policyExporter}), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + isConnected: true, + isSuccessful: true, + isAuthenticationError: false, + source: 'DIRECT', + }, + config: { + autoSync: { + jobID: 'job123', + enabled: false, // Auto-sync disabled so manual export is available + }, + export: { + exporter: connectionExporter, + }, + }, + }, + }, + }) as unknown as Policy; + +const createMockTransaction = (transactionID: string, reportID: string, overrides: Partial = {}): Transaction => + ({ + transactionID, + reportID, + amount: 100, + modifiedAmount: 0, + reimbursable: true, + status: CONST.TRANSACTION.STATUS.POSTED, + currency: 'USD', + merchant: 'Test Merchant', + created: '2024-01-01', + ...overrides, + }) as Transaction; + +// The utils take Onyx collections (keyed by full Onyx key) directly as arguments, so the tests build those keyed +// maps in-memory and call the utils without going through a hook or the live Onyx store. +const toReportsCollection = (reports: Report[]) => Object.fromEntries(reports.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report])); +const toTransactionsCollection = (transactions: Transaction[]) => + Object.fromEntries(transactions.map((transaction) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction])); +const toPoliciesCollection = (policies: Policy[]) => Object.fromEntries(policies.map((policy) => [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy])); + +const baseParams = { + allReportNameValuePairs: undefined, + allReportActions: undefined, + allReportMetadata: undefined, + personalDetailsList: undefined, + bankAccountList: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + login: CURRENT_USER_EMAIL, +}; + +describe('TodosUtils', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + // Some role checks read the current session, so keep it set for every test. + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await waitForBatchedUpdates(); + }); + + describe('buildTransactionsByReportID', () => { + it('returns an empty map when there are no transactions', () => { + expect(buildTransactionsByReportID(undefined)).toEqual({}); + expect(buildTransactionsByReportID({})).toEqual({}); + }); + + it('groups transactions by their report ID', () => { + const transactions = [createMockTransaction('t1', 'r1'), createMockTransaction('t2', 'r1'), createMockTransaction('t3', 'r2')]; + + const result = buildTransactionsByReportID(toTransactionsCollection(transactions)); + + expect(Object.keys(result)).toEqual(['r1', 'r2']); + expect(result.r1).toHaveLength(2); + expect(result.r2).toHaveLength(1); + expect(result.r1.map((transaction) => transaction.transactionID)).toEqual(['t1', 't2']); + }); + + it('skips transactions that have no report ID', () => { + const transactions = [createMockTransaction('t1', 'r1'), createMockTransaction('t2', '')]; + + const result = buildTransactionsByReportID(toTransactionsCollection(transactions)); + + expect(Object.keys(result)).toEqual(['r1']); + }); + }); + + describe('createTodosReportsAndTransactions', () => { + it('returns empty buckets when there are no reports', () => { + const result = createTodosReportsAndTransactions({ + ...baseParams, + allReports: undefined, + allTransactions: undefined, + allPolicies: undefined, + }); + + expect(result.reportsToSubmit).toEqual([]); + expect(result.reportsToApprove).toEqual([]); + expect(result.reportsToPay).toEqual([]); + expect(result.reportsToExport).toEqual([]); + expect(result.transactionsByReportID).toEqual({}); + }); + + describe('with a mix of reports across every bucket', () => { + const SUBMIT_REPORT_IDS = ['submit_1', 'submit_2', 'submit_3', 'submit_4']; + const APPROVE_REPORT_IDS = ['approve_1', 'approve_2', 'approve_3']; + const PAY_REPORT_IDS = ['pay_1', 'pay_2']; + const EXPORT_REPORT_ID = 'export_1'; + + const buildScenario = () => { + const reportsToSubmit = SUBMIT_REPORT_IDS.map((id) => + createMockReport(id, {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, ownerAccountID: CURRENT_USER_ACCOUNT_ID}), + ); + const reportsToApprove = APPROVE_REPORT_IDS.map((id) => + createMockReport(id, { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + }), + ); + const reportsToPay = PAY_REPORT_IDS.map((id) => + createMockReport(id, { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + total: -100, + }), + ); + const reportToExport = createMockReport(EXPORT_REPORT_ID, { + policyID: POLICY_WITH_CONNECTION_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + }); + // A chat report and an unrelated open report that belong in no bucket. + const excludedReports = [ + createMockReport('excluded_chat', {type: CONST.REPORT.TYPE.CHAT}), + createMockReport('excluded_other', {ownerAccountID: OTHER_USER_ACCOUNT_ID, managerID: OTHER_USER_ACCOUNT_ID}), + ]; + + const policy = createMockPolicy(POLICY_ID, { + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + }); + const policyWithConnection = createPolicyWithQBOConnection(POLICY_WITH_CONNECTION_ID, {policyExporter: CURRENT_USER_EMAIL, connectionExporter: CURRENT_USER_EMAIL}); + + const transactions = [ + ...SUBMIT_REPORT_IDS.map((reportID) => createMockTransaction(`trans_${reportID}`, reportID)), + ...APPROVE_REPORT_IDS.map((reportID) => createMockTransaction(`trans_${reportID}`, reportID)), + ...PAY_REPORT_IDS.map((reportID) => createMockTransaction(`trans_${reportID}`, reportID)), + ]; + + return { + ...baseParams, + allReports: toReportsCollection([...reportsToSubmit, ...reportsToApprove, ...reportsToPay, reportToExport, ...excludedReports]), + allTransactions: toTransactionsCollection(transactions), + allPolicies: toPoliciesCollection([policy, policyWithConnection]), + }; + }; + + it('classifies every report into its matching bucket', () => { + const result = createTodosReportsAndTransactions(buildScenario()); + + expect(result.reportsToSubmit.map((report) => report.reportID)).toEqual(SUBMIT_REPORT_IDS); + expect(result.reportsToApprove.map((report) => report.reportID)).toEqual(APPROVE_REPORT_IDS); + expect(result.reportsToPay.map((report) => report.reportID)).toEqual(PAY_REPORT_IDS); + expect(result.reportsToExport.map((report) => report.reportID)).toEqual([EXPORT_REPORT_ID]); + }); + + it('indexes transactions by report ID', () => { + const result = createTodosReportsAndTransactions(buildScenario()); + + expect(result.transactionsByReportID[SUBMIT_REPORT_IDS.at(0) ?? '']).toHaveLength(1); + expect(result.transactionsByReportID[APPROVE_REPORT_IDS.at(0) ?? '']).toHaveLength(1); + }); + }); + + it('excludes a report whose expenses are all on hold', () => { + const heldOverride: Partial = {comment: {hold: 'HOLD_ACTION_ID'}}; + const submitReport = createMockReport('held_submit', {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, ownerAccountID: CURRENT_USER_ACCOUNT_ID}); + const policy = createMockPolicy(POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, ownerAccountID: CURRENT_USER_ACCOUNT_ID}); + + const result = createTodosReportsAndTransactions({ + ...baseParams, + allReports: toReportsCollection([submitReport]), + allTransactions: toTransactionsCollection([createMockTransaction('trans_held', 'held_submit', heldOverride)]), + allPolicies: toPoliciesCollection([policy]), + }); + + expect(result.reportsToSubmit).toEqual([]); + }); + + it('ignores non-expense reports', () => { + const chatReport = createMockReport('chat_report', {type: CONST.REPORT.TYPE.CHAT, ownerAccountID: CURRENT_USER_ACCOUNT_ID}); + const policy = createMockPolicy(POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, ownerAccountID: CURRENT_USER_ACCOUNT_ID}); + + const result = createTodosReportsAndTransactions({ + ...baseParams, + allReports: toReportsCollection([chatReport]), + allTransactions: toTransactionsCollection([createMockTransaction('trans_chat', 'chat_report')]), + allPolicies: toPoliciesCollection([policy]), + }); + + expect(result.reportsToSubmit).toEqual([]); + expect(result.reportsToApprove).toEqual([]); + expect(result.reportsToPay).toEqual([]); + expect(result.reportsToExport).toEqual([]); + }); + }); + + describe('getTodoReportsForSearchKey', () => { + const buildParams = () => { + const submitReport = createMockReport('submit_only', {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, ownerAccountID: CURRENT_USER_ACCOUNT_ID}); + const approveReport = createMockReport('approve_only', { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + }); + const policy = createMockPolicy(POLICY_ID, { + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + }); + + return { + ...baseParams, + allReports: toReportsCollection([submitReport, approveReport]), + allTransactions: toTransactionsCollection([createMockTransaction('trans_submit', 'submit_only'), createMockTransaction('trans_approve', 'approve_only')]), + allPolicies: toPoliciesCollection([policy]), + }; + }; + + it('returns only the reports for the requested bucket', () => { + const params = buildParams(); + + const submitResult = getTodoReportsForSearchKey(CONST.SEARCH.SEARCH_KEYS.SUBMIT, params); + expect(submitResult.reports.map((report) => report.reportID)).toEqual(['submit_only']); + + const approveResult = getTodoReportsForSearchKey(CONST.SEARCH.SEARCH_KEYS.APPROVE, params); + expect(approveResult.reports.map((report) => report.reportID)).toEqual(['approve_only']); + }); + + it('still indexes every transaction by report ID regardless of the requested bucket', () => { + const result = getTodoReportsForSearchKey(CONST.SEARCH.SEARCH_KEYS.SUBMIT, buildParams()); + + expect(Object.keys(result.transactionsByReportID).sort()).toEqual(['approve_only', 'submit_only']); + }); + + it('excludes a report whose expenses are all on hold from its bucket', () => { + const heldOverride: Partial = {comment: {hold: 'HOLD_ACTION_ID'}}; + const submitReport = createMockReport('held_submit', {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, ownerAccountID: CURRENT_USER_ACCOUNT_ID}); + const policy = createMockPolicy(POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, ownerAccountID: CURRENT_USER_ACCOUNT_ID}); + + const result = getTodoReportsForSearchKey(CONST.SEARCH.SEARCH_KEYS.SUBMIT, { + ...baseParams, + allReports: toReportsCollection([submitReport]), + allTransactions: toTransactionsCollection([createMockTransaction('trans_held', 'held_submit', heldOverride)]), + allPolicies: toPoliciesCollection([policy]), + }); + + expect(result.reports).toEqual([]); + }); + }); +}); From ae9c537628e6f6b9099ae8c65ccc684c16d012c6 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 29 Jun 2026 16:44:16 +0200 Subject: [PATCH 16/16] fix lint in TodosUtilsTest --- tests/unit/TodosUtilsTest.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index 0f729b6f5828..8fbd4258ba2b 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -3,6 +3,7 @@ import createTodosReportsAndTransactions, {buildTransactionsByReportID, getTodoR import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, Transaction} from '@src/types/onyx'; +import createMock from '../utils/createMock'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const CURRENT_USER_ACCOUNT_ID = 1; @@ -44,10 +45,10 @@ const createMockPolicy = (policyID: string, overrides: Partial = {}): Po }); // Admin policy with a QBO connection whose auto-sync is disabled, so a report on it is manually exportable. -// The `Connections` type requires an entry for every supported integration, so a single-integration literal can -// only be supplied through an assertion - isolated here so it is the one such cast in this file. +// `Policy['connections']` is typed as the full integration map, so the single-integration literal goes through +// `createMock`, which type-checks the partial and isolates the one unavoidable assertion in that helper. const createPolicyWithQBOConnection = (policyID: string, {policyExporter, connectionExporter}: {policyExporter: string; connectionExporter: string}): Policy => - ({ + createMock({ ...createMockPolicy(policyID, {role: CONST.POLICY.ROLE.ADMIN, exporter: policyExporter}), connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { @@ -68,7 +69,7 @@ const createPolicyWithQBOConnection = (policyID: string, {policyExporter, connec }, }, }, - }) as unknown as Policy; + }); const createMockTransaction = (transactionID: string, reportID: string, overrides: Partial = {}): Transaction => ({