From 7bf8951dcfa99293a1e80b404641884d3d539f58 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Mon, 27 Jul 2026 15:12:06 +0430 Subject: [PATCH 01/11] Add Create actions to Search Router navigation --- .../SearchRouter/SearchRouterHelpers.ts | 16 +- .../useCreateNavigationSuggestions.ts | 219 ++++++++++++++++++ .../SearchRouter/useNavigationSuggestions.tsx | 4 +- tests/unit/SearchRouterNavigationTest.ts | 61 +++++ 4 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts diff --git a/src/components/Search/SearchRouter/SearchRouterHelpers.ts b/src/components/Search/SearchRouter/SearchRouterHelpers.ts index 8ad3dcb86008..3ebebe7cdb90 100644 --- a/src/components/Search/SearchRouter/SearchRouterHelpers.ts +++ b/src/components/Search/SearchRouter/SearchRouterHelpers.ts @@ -32,22 +32,22 @@ function isNavigationIntentOnlyQuery(query: string) { return /^go(?:\s+to)?$/i.test(query.trim()); } +function normalizeNavigationText(value: string) { + return StringUtils.normalizeAccents(value).toLowerCase().replaceAll(/\s+/g, ' ').trim(); +} + function matchesNavigationQuery(query: string, ...values: Array) { - const normalizedQuery = StringUtils.normalizeAccents(query).toLowerCase(); + const normalizedQuery = normalizeNavigationText(query); if (!normalizedQuery) { return false; } - return values.some((value) => - StringUtils.normalizeAccents(value ?? '') - .toLowerCase() - .includes(normalizedQuery), - ); + return values.some((value) => normalizeNavigationText(value ?? '').includes(normalizedQuery)); } function matchesNavigationQueryExactly(query: string, ...values: Array) { - const normalizedQuery = StringUtils.normalizeAccents(query).toLowerCase(); - return values.some((value) => StringUtils.normalizeAccents(value ?? '').toLowerCase() === normalizedQuery); + const normalizedQuery = normalizeNavigationText(query); + return values.some((value) => normalizeNavigationText(value ?? '') === normalizedQuery); } function sortNavigationSuggestionItems(items: T[], localeCompare: LocaleContextProps['localeCompare']): T[] { diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts new file mode 100644 index 000000000000..ef78f8b63b22 --- /dev/null +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -0,0 +1,219 @@ +import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; + +import useCreateReport from '@hooks/useCreateReport'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; + +import {startDistanceRequest, startMoneyRequest} from '@libs/actions/IOU/MoneyRequest'; +import {createNewReport, startNewChat} from '@libs/actions/Report'; +import getIconForAction from '@libs/getIconForAction'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {canSendInvoice, getDefaultChatEnabledPolicy, isGroupPolicy, shouldShowPolicy} from '@libs/PolicyUtils'; +import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; + +import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; + +import {clearLastSearchParams} from '@userActions/ReportNavigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; +import {emailSelector, sessionEmailAndAccountIDSelector} from '@src/selectors/Session'; +import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {useState} from 'react'; + +import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers'; + +type CreateNavigationItem = { + visible: boolean; + text: string; + icon: IconAsset; + action: () => void; + keyForList: string; +}; + +const chatEnabledGroupPoliciesSelector = (policies: OnyxCollection, currentUserLogin: string | undefined) => { + if (isEmptyObject(policies)) { + return getEmptyArray(); + } + + const result: OnyxTypes.Policy[] = []; + for (const policy of Object.values(policies)) { + if (!policy?.isPolicyExpenseChatEnabled || policy.isJoinRequestPending || !isGroupPolicy(policy) || !shouldShowPolicy(policy, false, currentUserLogin)) { + continue; + } + + result.push(policy); + if (result.length === 2) { + break; + } + } + + return result; +}; + +function buildCreateNavigationItems(items: CreateNavigationItem[]): NavigationSuggestionSourceItem[] { + return items + .filter((item) => item.visible) + .map(({text, icon, action, keyForList}) => ({ + text, + singleIcon: icon, + action, + keyForList, + matchTerms: [text], + })); +} + +function replaceTopmostModalWithAction(action: () => void) { + if (!Navigation.isTopmostRouteModalScreen()) { + action(); + return; + } + + Navigation.dismissModal({afterTransition: action}); +} + +function useCreateNavigationSuggestions(): SearchQueryItem[] { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Coins', 'Receipt', 'Cash', 'Transfer', 'MoneyCircle', 'Location', 'Document', 'ChatBubble', 'InvoiceGeneric', 'NewWorkspace']); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const {isOffline} = useNetwork(); + const {isRestrictedPolicyCreation} = usePreferredPolicy(); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [reportID] = useState(() => generateReportID()); + const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); + const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); + const [session] = useOnyx(ONYXKEYS.SESSION, {selector: sessionEmailAndAccountIDSelector}); + const [allBetas] = useOnyx(ONYXKEYS.BETAS); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); + const [groupPoliciesWithChatEnabled = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies: Parameters[0]) => chatEnabledGroupPoliciesSelector(policies, session?.email), + }); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP); + + const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled, activePolicy); + const hasViolations = hasViolationsReportUtils(undefined, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); + const isReportInSearch = isOnSearchMoneyRequestReportPage(); + const isInvoiceVisible = canSendInvoice(allPolicies ?? null, sessionEmail); + + const {createReport, isVisible: isCreateReportVisible} = useCreateReport({ + onCreateReport: (shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!defaultChatEnabledPolicy?.id) { + return; + } + + if (isReportInSearch) { + clearLastSearchParams(); + } + + const {reportID: createdReportID} = createNewReport( + currentUserPersonalDetails, + hasViolations, + isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + defaultChatEnabledPolicy, + allBetas, + isTrackIntentUser, + false, + shouldDismissEmptyReportsConfirmation, + ); + Navigation.navigate(getReportsRootRoute(), {forceReplace: isReportInSearch}); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(getCreateReportRoute({reportID: createdReportID}), {forceReplace: isReportInSearch}); + }); + }, + groupPoliciesWithChatEnabled, + onNavigateToWorkspaceSelection: () => navigateToCreateReportWorkspaceSelection({forceReplace: isReportInSearch}), + shouldHandleNavigationBack: false, + }); + + const shouldShowNewWorkspaceButton = + !isRestrictedPolicyCreation && !isLoading && Object.values(allPolicies ?? {}).every((policy) => !shouldShowPolicy(policy, !!isOffline, sessionEmail)); + + return buildCreateNavigationItems([ + { + visible: true, + text: translate('iou.createExpense'), + icon: getIconForAction(CONST.IOU.TYPE.CREATE, icons), + action: () => + replaceTopmostModalWithAction(() => { + interceptAnonymousUser(() => { + startMoneyRequest(CONST.IOU.TYPE.CREATE, reportID, draftTransactionIDs, undefined, undefined, undefined, true); + }); + }), + keyForList: 'create_expense', + }, + { + visible: isCreateReportVisible, + text: translate('report.newReport.createReport'), + icon: icons.Document, + action: () => replaceTopmostModalWithAction(createReport), + keyForList: 'create_report', + }, + { + visible: true, + text: translate('iou.trackDistance'), + icon: icons.Location, + action: () => + replaceTopmostModalWithAction(() => { + interceptAnonymousUser(() => { + startDistanceRequest(CONST.IOU.TYPE.CREATE, reportID, draftTransactionIDs, lastDistanceExpenseType, undefined, undefined, true); + }); + }), + keyForList: 'create_trackDistance', + }, + { + visible: true, + text: translate('sidebarScreen.fabNewChat'), + icon: icons.ChatBubble, + action: () => replaceTopmostModalWithAction(() => interceptAnonymousUser(startNewChat)), + keyForList: 'create_chat', + }, + { + visible: isInvoiceVisible, + text: translate('workspace.invoices.sendInvoice'), + icon: icons.InvoiceGeneric, + action: () => + replaceTopmostModalWithAction(() => { + interceptAnonymousUser(() => { + startMoneyRequest(CONST.IOU.TYPE.INVOICE, reportID, draftTransactionIDs, undefined, undefined, undefined, true); + }); + }), + keyForList: 'create_invoice', + }, + { + visible: shouldShowNewWorkspaceButton, + text: translate('workspace.new.newWorkspace'), + icon: icons.NewWorkspace, + action: () => + replaceTopmostModalWithAction(() => { + interceptAnonymousUser(() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CONFIRMATION.path))); + }), + keyForList: 'create_workspace', + }, + ]); +} + +export default useCreateNavigationSuggestions; +export {buildCreateNavigationItems, replaceTopmostModalWithAction}; +export type {CreateNavigationItem}; diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index f6ef5c1f18e9..2298bdaff96d 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -20,6 +20,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers'; import {buildNavigationSuggestions, getGoToText} from './SearchRouterHelpers'; +import useCreateNavigationSuggestions from './useCreateNavigationSuggestions'; type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Building' | 'Gear', IconAsset>; @@ -81,6 +82,7 @@ function useNavigationSuggestions(query: string): SearchQueryItem[] { const {translate, localeCompare} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Home', 'Inbox', 'ReceiptMultiple', 'Building', 'Gear']); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const createItems = useCreateNavigationSuggestions(); const topLevelItems = buildTopLevelNavigationItems({ labels: { @@ -95,7 +97,7 @@ function useNavigationSuggestions(query: string): SearchQueryItem[] { getDestinationText: (destination) => getGoToText(translate, destination), }); - return buildNavigationSuggestions(query, [topLevelItems], localeCompare); + return buildNavigationSuggestions(query, [topLevelItems, createItems], localeCompare); } export default useNavigationSuggestions; diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 1d3f0e968c34..4fc24503e7ad 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -6,6 +6,8 @@ import { sortNavigationSuggestionItems, stripNavigationIntentPrefix, } from '@components/Search/SearchRouter/SearchRouterHelpers'; +import {buildCreateNavigationItems, replaceTopmostModalWithAction} from '@components/Search/SearchRouter/useCreateNavigationSuggestions'; +import type {CreateNavigationItem} from '@components/Search/SearchRouter/useCreateNavigationSuggestions'; import {buildTopLevelNavigationItems} from '@components/Search/SearchRouter/useNavigationSuggestions'; import Navigation from '@libs/Navigation/Navigation'; @@ -16,6 +18,8 @@ import type IconAsset from '@src/types/utils/IconAsset'; jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, default: { + dismissModal: jest.fn(), + isTopmostRouteModalScreen: jest.fn(() => false), navigate: jest.fn(), }, })); @@ -180,3 +184,60 @@ describe('top-level Search Router navigation source', () => { expect(Navigation.navigate).toHaveBeenNthCalledWith(5, ROUTES.SETTINGS); }); }); + +describe('Create Search Router navigation source', () => { + const createAction = jest.fn(); + const createItems: CreateNavigationItem[] = [ + {visible: true, text: 'Create expense', icon: mockIcon, action: createAction, keyForList: 'create_expense'}, + {visible: true, text: 'Create report', icon: mockIcon, action: createAction, keyForList: 'create_report'}, + {visible: true, text: 'Track distance', icon: mockIcon, action: createAction, keyForList: 'create_trackDistance'}, + {visible: true, text: 'New chat', icon: mockIcon, action: createAction, keyForList: 'create_chat'}, + {visible: false, text: 'Create invoice', icon: mockIcon, action: createAction, keyForList: 'create_invoice'}, + {visible: false, text: 'New workspace', icon: mockIcon, action: createAction, keyForList: 'create_workspace'}, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('builds visible Create rows with direct action labels and excludes unavailable items', () => { + const items = buildCreateNavigationItems(createItems); + + expect(items.map((item) => item.text)).toEqual(['Create expense', 'Create report', 'Track distance', 'New chat']); + expect(items.map((item) => item.keyForList)).toEqual(['create_expense', 'create_report', 'create_trackDistance', 'create_chat']); + expect(items.map((item) => item.singleIcon)).toEqual([mockIcon, mockIcon, mockIcon, mockIcon]); + expect(items.map((item) => item.matchTerms)).toEqual([['Create expense'], ['Create report'], ['Track distance'], ['New chat']]); + expect(items.some((item) => item.text?.startsWith('Go to'))).toBe(false); + expect(items.some((item) => item.keyForList === 'create_invoice' || item.keyForList === 'create_workspace')).toBe(false); + expect(items.some((item) => item.keyForList === 'create_travel' || item.keyForList === 'create_quickAction')).toBe(false); + }); + + it('matches Create rows through the existing navigation suggestion pipeline', () => { + const items = buildCreateNavigationItems(createItems); + + expect(buildNavigationSuggestions('expense', [items], localeCompare).map((item) => item.keyForList)).toEqual(['create_expense']); + expect(buildNavigationSuggestions('go to track distance', [items], localeCompare).map((item) => item.keyForList)).toEqual(['create_trackDistance']); + expect(buildNavigationSuggestions('go to create expense', [items], localeCompare).map((item) => item.keyForList)).toEqual(['create_expense']); + }); + + it('runs an action immediately when no RHP is open', () => { + jest.mocked(Navigation.isTopmostRouteModalScreen).mockReturnValue(false); + + replaceTopmostModalWithAction(createAction); + + expect(createAction).toHaveBeenCalledTimes(1); + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + it('dismisses an existing RHP before running the Create action', () => { + jest.mocked(Navigation.isTopmostRouteModalScreen).mockReturnValue(true); + + replaceTopmostModalWithAction(createAction); + + expect(createAction).not.toHaveBeenCalled(); + expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + const afterTransition = jest.mocked(Navigation.dismissModal).mock.calls.at(0)?.at(0)?.afterTransition; + afterTransition?.(); + expect(createAction).toHaveBeenCalledTimes(1); + }); +}); From 8ac691f55ac27373383ae33d395752aa2eb6f41a Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Thu, 30 Jul 2026 16:39:21 +0430 Subject: [PATCH 02/11] Fix Create navigation hook mock in Search Router tests --- tests/unit/SearchRouterNavigationTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index c2c723857a39..36a567c98a93 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -39,6 +39,7 @@ jest.mock('@components/Search/SearchContext', () => ({ })); jest.mock('@components/Search/SearchRouter/useCreateNavigationSuggestions', () => ({ + __esModule: true, ...jest.requireActual('@components/Search/SearchRouter/useCreateNavigationSuggestions'), default: () => [], })); From 3ad46601fefcd3bb969e8fa3c0aeb498651d5bcd Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Thu, 30 Jul 2026 16:56:45 +0430 Subject: [PATCH 03/11] Fix Search Router performance test navigation mock --- tests/perf-test/SearchRouter.perf-test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 1575dc664c20..75c48628fb72 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -51,7 +51,10 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ jest.mock('@src/hooks/useRootNavigationState', () => ({ __esModule: true, - default: () => ({contextualReportID: undefined, isSearchRouterScreen: false}), + default: () => ({ + contextualReportID: undefined, + isSearchRouterScreen: false, + }), })); jest.mock('@hooks/useExportedToFilterOptions', () => ({ @@ -81,6 +84,7 @@ jest.mock('@react-navigation/native', () => { isReady: () => jest.fn(), getCurrentRoute: () => jest.fn(), getState: () => jest.fn(), + getRootState: () => undefined, }), useNavigationState: () => ({ routes: [], From 1ca043de40aaf771067bc231b52339a024096550 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 31 Jul 2026 11:20:03 +0430 Subject: [PATCH 04/11] Add Create navigation hook file header --- .../Search/SearchRouter/useCreateNavigationSuggestions.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index ef78f8b63b22..c687533d6c4a 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -1,3 +1,6 @@ +/** + * Builds the Create/FAB navigation suggestions shown in the Search Router. + */ import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import useCreateReport from '@hooks/useCreateReport'; From b02b3a9bd79748fd0a9369d3e3cf912488746096 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 31 Jul 2026 11:21:46 +0430 Subject: [PATCH 05/11] Replace Create policy limit magic value --- .../Search/SearchRouter/useCreateNavigationSuggestions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index c687533d6c4a..0c8831d75be8 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -51,6 +51,8 @@ type CreateNavigationItem = { keyForList: string; }; +const MAX_CHAT_ENABLED_POLICIES_TO_EVALUATE = 2; + const chatEnabledGroupPoliciesSelector = (policies: OnyxCollection, currentUserLogin: string | undefined) => { if (isEmptyObject(policies)) { return getEmptyArray(); @@ -63,7 +65,7 @@ const chatEnabledGroupPoliciesSelector = (policies: OnyxCollection Date: Fri, 31 Jul 2026 11:58:11 +0430 Subject: [PATCH 06/11] Use eligible report policies for Create suggestions --- .../useCreateNavigationSuggestions.ts | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index 0c8831d75be8..bca86d9f6787 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -19,7 +19,7 @@ import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {canSendInvoice, getDefaultChatEnabledPolicy, isGroupPolicy, shouldShowPolicy} from '@libs/PolicyUtils'; +import {canSendInvoice, getDefaultChatEnabledPolicy, getGroupPoliciesWhereReportCanBeCreated, shouldShowPolicy} from '@libs/PolicyUtils'; import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; @@ -33,11 +33,10 @@ import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; import {emailSelector, sessionEmailAndAccountIDSelector} from '@src/selectors/Session'; import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'; import type * as OnyxTypes from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useState} from 'react'; @@ -51,28 +50,6 @@ type CreateNavigationItem = { keyForList: string; }; -const MAX_CHAT_ENABLED_POLICIES_TO_EVALUATE = 2; - -const chatEnabledGroupPoliciesSelector = (policies: OnyxCollection, currentUserLogin: string | undefined) => { - if (isEmptyObject(policies)) { - return getEmptyArray(); - } - - const result: OnyxTypes.Policy[] = []; - for (const policy of Object.values(policies)) { - if (!policy?.isPolicyExpenseChatEnabled || policy.isJoinRequestPending || !isGroupPolicy(policy) || !shouldShowPolicy(policy, false, currentUserLogin)) { - continue; - } - - result.push(policy); - if (result.length === MAX_CHAT_ENABLED_POLICIES_TO_EVALUATE) { - break; - } - } - - return result; -}; - function buildCreateNavigationItems(items: CreateNavigationItem[]): NavigationSuggestionSourceItem[] { return items .filter((item) => item.visible) @@ -99,6 +76,7 @@ function useCreateNavigationSuggestions(): SearchQueryItem[] { const icons = useMemoizedLazyExpensifyIcons(['Coins', 'Receipt', 'Cash', 'Transfer', 'MoneyCircle', 'Location', 'Document', 'ChatBubble', 'InvoiceGeneric', 'NewWorkspace']); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isBetaEnabled} = usePermissions(); + const isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026); const {isOffline} = useNetwork(); const {isRestrictedPolicyCreation} = usePreferredPolicy(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -112,12 +90,12 @@ function useCreateNavigationSuggestions(): SearchQueryItem[] { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [groupPoliciesWithChatEnabled = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: (policies: Parameters[0]) => chatEnabledGroupPoliciesSelector(policies, session?.email), + selector: (policies: OnyxCollection) => getGroupPoliciesWhereReportCanBeCreated(policies, isSubmit2026BetaEnabled, session?.email), }); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled, activePolicy); + const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy); const hasViolations = hasViolationsReportUtils(undefined, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); const isReportInSearch = isOnSearchMoneyRequestReportPage(); const isInvoiceVisible = canSendInvoice(allPolicies ?? null, sessionEmail); From 90e4f11ed65eb248f0f47155974b720762df5fad Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 31 Jul 2026 12:16:37 +0430 Subject: [PATCH 07/11] Remove unsafe Create policy assertion --- .../Search/SearchRouter/useCreateNavigationSuggestions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index bca86d9f6787..4d3c18218707 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -36,7 +36,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import {useState} from 'react'; @@ -95,7 +95,7 @@ function useCreateNavigationSuggestions(): SearchQueryItem[] { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy); + const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy([...groupPoliciesWithChatEnabled], activePolicy); const hasViolations = hasViolationsReportUtils(undefined, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); const isReportInSearch = isOnSearchMoneyRequestReportPage(); const isInvoiceVisible = canSendInvoice(allPolicies ?? null, sessionEmail); From 19da82e6a311012915c749f3a2868b73b6d88df6 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 31 Jul 2026 15:32:12 +0430 Subject: [PATCH 08/11] Document Create report policy eligibility --- .../Search/SearchRouter/useCreateNavigationSuggestions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index 4d3c18218707..15fea5ff10f6 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -89,6 +89,7 @@ function useCreateNavigationSuggestions(): SearchQueryItem[] { const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); + // Use the shared report eligibility rules so Submit workspaces are only included for beta users. const [groupPoliciesWithChatEnabled = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: (policies: OnyxCollection) => getGroupPoliciesWhereReportCanBeCreated(policies, isSubmit2026BetaEnabled, session?.email), }); From 4a0f22a258fb980b372209aadc95e7114f685bb0 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 31 Jul 2026 15:39:48 +0430 Subject: [PATCH 09/11] Document Create report navigation ordering --- .../Search/SearchRouter/useCreateNavigationSuggestions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index 15fea5ff10f6..27092afbb19c 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -121,6 +121,8 @@ function useCreateNavigationSuggestions(): SearchQueryItem[] { false, shouldDismissEmptyReportsConfirmation, ); + // Navigate to the Reports page first so getCreateReportRoute() resolves against + // the Search/Reports fullscreen context before opening the created report modal. Navigation.navigate(getReportsRootRoute(), {forceReplace: isReportInSearch}); Navigation.setNavigationActionToMicrotaskQueue(() => { Navigation.navigate(getCreateReportRoute({reportID: createdReportID}), {forceReplace: isReportInSearch}); From 68ed8d41ba6bb441906313b5bdcc0a952b232208 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 31 Jul 2026 15:46:48 +0430 Subject: [PATCH 10/11] Document Create modal replacement behavior --- .../Search/SearchRouter/useCreateNavigationSuggestions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index 27092afbb19c..0df3cbcfe85f 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -62,6 +62,8 @@ function buildCreateNavigationItems(items: CreateNavigationItem[]): NavigationSu })); } +// Search Router is already hidden when this runs, so the topmost modal is an underlying RHP. +// Wait for it to close before opening the Create flow to avoid stacking modal routes. function replaceTopmostModalWithAction(action: () => void) { if (!Navigation.isTopmostRouteModalScreen()) { action(); From 413d46aadce6a690fb05700704c0f8e52c3daf30 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 31 Jul 2026 16:11:10 +0430 Subject: [PATCH 11/11] Add Create navigation hook coverage --- .../useCreateNavigationSuggestionsTest.ts | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 tests/unit/useCreateNavigationSuggestionsTest.ts diff --git a/tests/unit/useCreateNavigationSuggestionsTest.ts b/tests/unit/useCreateNavigationSuggestionsTest.ts new file mode 100644 index 000000000000..eb04de5195ba --- /dev/null +++ b/tests/unit/useCreateNavigationSuggestionsTest.ts @@ -0,0 +1,240 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useCreateNavigationSuggestions from '@components/Search/SearchRouter/useCreateNavigationSuggestions'; + +import {startDistanceRequest, startMoneyRequest} from '@libs/actions/IOU/MoneyRequest'; +import {createNewReport, startNewChat} from '@libs/actions/Report'; +import Navigation from '@libs/Navigation/Navigation'; + +import {clearLastSearchParams} from '@userActions/ReportNavigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type MockUseCreateReportParams = { + onCreateReport: (shouldDismissEmptyReportsConfirmation?: boolean) => void; + groupPoliciesWithChatEnabled: unknown[] | readonly never[]; + onNavigateToWorkspaceSelection: () => void; + shouldHandleNavigationBack: boolean; +}; + +type MockOnyxOptions = { + selector?: (value: unknown) => unknown; +}; + +const mockCreateReport = jest.fn(); +let mockCreateReportIsVisible = true; +const mockUseCreateReport = jest.fn<{createReport: typeof mockCreateReport; isVisible: boolean}, [MockUseCreateReportParams]>(() => ({ + createReport: mockCreateReport, + isVisible: mockCreateReportIsVisible, +})); +const mockUseOnyx = jest.fn(); +const mockIsBetaEnabled = jest.fn((beta: string) => beta !== CONST.BETAS.SUBMIT_2026); +const mockCanSendInvoice = jest.fn(() => false); +const mockGetDefaultChatEnabledPolicy = jest.fn((policies: unknown[]) => (policies.length === 1 ? policies.at(0) : undefined)); +const mockGetGroupPoliciesWhereReportCanBeCreated = jest.fn(); +const mockShouldShowPolicy = jest.fn(() => true); +const mockIcon = () => null; + +jest.mock('@hooks/useCreateReport', () => ({ + __esModule: true, + default: (params: MockUseCreateReportParams) => mockUseCreateReport(params), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: 1, login: 'test@example.com'}), +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({ + Document: mockIcon, + Location: mockIcon, + ChatBubble: mockIcon, + InvoiceGeneric: mockIcon, + NewWorkspace: mockIcon, + }), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: false}), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string, options?: MockOnyxOptions) => mockUseOnyx(key, options), +})); + +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: mockIsBetaEnabled}), +})); + +jest.mock('@hooks/usePreferredPolicy', () => ({ + __esModule: true, + default: () => ({isRestrictedPolicyCreation: false}), +})); + +jest.mock('@libs/actions/IOU/MoneyRequest', () => ({ + startDistanceRequest: jest.fn(), + startMoneyRequest: jest.fn(), +})); + +jest.mock('@libs/actions/Report', () => ({ + createNewReport: jest.fn(() => ({reportID: 'created-report'})), + startNewChat: jest.fn(), +})); + +jest.mock('@libs/getIconForAction', () => ({ + __esModule: true, + default: () => mockIcon, +})); + +jest.mock('@libs/interceptAnonymousUser', () => ({ + __esModule: true, + default: (action: () => void) => action(), +})); + +jest.mock('@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute', () => ({ + __esModule: true, + default: () => 'workspace-confirmation', +})); + +jest.mock('@libs/Navigation/helpers/getCreateReportRoute', () => ({ + __esModule: true, + default: ({reportID}: {reportID: string}) => `report/${reportID}`, + getReportsRootRoute: () => 'reports', + navigateToCreateReportWorkspaceSelection: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + dismissModal: jest.fn(), + isTopmostRouteModalScreen: jest.fn(() => false), + navigate: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn((action: () => void) => action()), + }, +})); + +jest.mock('@libs/PolicyUtils', () => ({ + canSendInvoice: (...args: unknown[]) => mockCanSendInvoice(...args), + getDefaultChatEnabledPolicy: (policies: unknown[]) => mockGetDefaultChatEnabledPolicy(policies), + getGroupPoliciesWhereReportCanBeCreated: (policies: unknown, isSubmit2026BetaEnabled: boolean, currentUserLogin?: string) => + mockGetGroupPoliciesWhereReportCanBeCreated(policies, isSubmit2026BetaEnabled, currentUserLogin), + shouldShowPolicy: (...args: unknown[]) => mockShouldShowPolicy(...args), +})); + +jest.mock('@libs/ReportUtils', () => ({ + generateReportID: jest.fn(() => 'draft-report'), + hasViolations: jest.fn(() => false), +})); + +jest.mock('@navigation/helpers/isOnSearchMoneyRequestReportPage', () => ({ + __esModule: true, + default: () => false, +})); + +jest.mock('@userActions/ReportNavigation', () => ({ + clearLastSearchParams: jest.fn(), +})); + +const submitPolicy = {id: 'submit-policy', type: CONST.POLICY.TYPE.SUBMIT}; +const policies = {[`${ONYXKEYS.COLLECTION.POLICY}${submitPolicy.id}`]: submitPolicy}; +const session = {accountID: 1, email: 'test@example.com'}; + +function setupUseOnyx() { + const values = new Map([ + [ONYXKEYS.COLLECTION.POLICY, policies], + [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {}], + [ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, CONST.IOU.REQUEST_TYPE.DISTANCE_MAP], + [ONYXKEYS.SESSION, session], + [ONYXKEYS.BETAS, []], + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {}], + [ONYXKEYS.NVP_ACTIVE_POLICY_ID, submitPolicy.id], + [`${ONYXKEYS.COLLECTION.POLICY}${submitPolicy.id}`, submitPolicy], + [ONYXKEYS.NVP_INTRO_SELECTED, false], + [ONYXKEYS.IS_LOADING_APP, false], + ]); + + mockUseOnyx.mockImplementation((key, options) => { + const value = values.get(key); + return [options?.selector ? options.selector(value) : value, {status: 'loaded'}]; + }); +} + +describe('useCreateNavigationSuggestions', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupUseOnyx(); + mockCanSendInvoice.mockReturnValue(false); + mockShouldShowPolicy.mockReturnValue(true); + mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([]); + mockIsBetaEnabled.mockImplementation((beta) => beta !== CONST.BETAS.SUBMIT_2026); + mockCreateReportIsVisible = true; + }); + + it('uses beta-aware report policies and renders only available Create actions', () => { + mockCreateReportIsVisible = false; + const {result} = renderHook(() => useCreateNavigationSuggestions()); + + expect(mockGetGroupPoliciesWhereReportCanBeCreated).toHaveBeenCalledWith(policies, false, session.email); + expect(mockUseCreateReport).toHaveBeenCalledWith( + expect.objectContaining({ + groupPoliciesWithChatEnabled: [], + shouldHandleNavigationBack: false, + }), + ); + expect(result.current.map((item) => item.keyForList)).toEqual(['create_expense', 'create_trackDistance', 'create_chat']); + }); + + it('passes Submit eligibility and exposes permission-gated actions', () => { + mockIsBetaEnabled.mockReturnValue(true); + mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([submitPolicy]); + mockCanSendInvoice.mockReturnValue(true); + mockShouldShowPolicy.mockReturnValue(false); + + const {result} = renderHook(() => useCreateNavigationSuggestions()); + + expect(mockGetGroupPoliciesWhereReportCanBeCreated).toHaveBeenCalledWith(policies, true, session.email); + expect(mockUseCreateReport).toHaveBeenCalledWith(expect.objectContaining({groupPoliciesWithChatEnabled: [submitPolicy]})); + expect(result.current.map((item) => item.keyForList)).toEqual(['create_expense', 'create_report', 'create_trackDistance', 'create_chat', 'create_invoice', 'create_workspace']); + + act(() => result.current.find((item) => item.keyForList === 'create_invoice')?.action?.()); + act(() => result.current.find((item) => item.keyForList === 'create_workspace')?.action?.()); + + expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.INVOICE, 'draft-report', expect.anything(), undefined, undefined, undefined, true); + expect(Navigation.navigate).toHaveBeenCalledWith('workspace-confirmation'); + }); + + it('reuses the generated report ID and saved distance type for Create actions', () => { + const {result} = renderHook(() => useCreateNavigationSuggestions()); + + act(() => result.current.find((item) => item.keyForList === 'create_expense')?.action?.()); + act(() => result.current.find((item) => item.keyForList === 'create_trackDistance')?.action?.()); + act(() => result.current.find((item) => item.keyForList === 'create_chat')?.action?.()); + + expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.CREATE, 'draft-report', expect.anything(), undefined, undefined, undefined, true); + expect(startDistanceRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.CREATE, 'draft-report', expect.anything(), CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, undefined, undefined, true); + expect(startNewChat).toHaveBeenCalledTimes(1); + }); + + it('creates a report and navigates through the Reports root', () => { + mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([submitPolicy]); + renderHook(() => useCreateNavigationSuggestions()); + + const onCreateReport = mockUseCreateReport.mock.calls.at(0)?.at(0)?.onCreateReport; + act(() => onCreateReport?.(true)); + + expect(createNewReport).toHaveBeenCalledWith(expect.anything(), false, true, submitPolicy, [], false, false, true); + expect(clearLastSearchParams).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, 'reports', {forceReplace: false}); + expect(Navigation.navigate).toHaveBeenNthCalledWith(2, 'report/created-report', {forceReplace: false}); + }); +});