diff --git a/src/components/Search/SearchRouter/SearchRouterHelpers.ts b/src/components/Search/SearchRouter/SearchRouterHelpers.ts index 2b6328cdf768..a876d70aa921 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..0df3cbcfe85f --- /dev/null +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -0,0 +1,207 @@ +/** + * 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'; +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, getGroupPoliciesWhereReportCanBeCreated, 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 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; +}; + +function buildCreateNavigationItems(items: CreateNavigationItem[]): NavigationSuggestionSourceItem[] { + return items + .filter((item) => item.visible) + .map(({text, icon, action, keyForList}) => ({ + text, + singleIcon: icon, + action, + keyForList, + matchTerms: [text], + })); +} + +// 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(); + 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 isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + 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}`); + // 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), + }); + 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, + ); + // 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}); + }); + }, + 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 37b3f9ba561c..e460a77f623f 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -1,5 +1,5 @@ /** - * Builds the top-level and Spend navigation suggestions shown in the Search Router. + * Builds the top-level, Spend, and Create navigation suggestions shown in the Search Router. */ import getSearchTabRoute from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; @@ -33,6 +33,7 @@ import React from 'react'; import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers'; import {buildNavigationSuggestions, getGoToText} from './SearchRouterHelpers'; +import useCreateNavigationSuggestions from './useCreateNavigationSuggestions'; type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Building' | 'Gear', IconAsset>; type SpendNavigationIcons = Record; @@ -128,6 +129,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(SEARCH_ROUTER_ICON_NAMES); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const createItems = useCreateNavigationSuggestions(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {typeMenuSections} = useSearchTypeMenuSections(undefined, shouldWatchForApprovals); @@ -161,7 +163,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) onSelect: (searchQuery) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions), }); - return buildNavigationSuggestions(query, [topLevelItems, spendItems], localeCompare); + return buildNavigationSuggestions(query, [topLevelItems, spendItems, createItems], localeCompare); } export default useNavigationSuggestions; 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: [], diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 5cee95dedfba..36a567c98a93 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -8,6 +8,7 @@ import { sortNavigationSuggestionItems, stripNavigationIntentPrefix, } from '@components/Search/SearchRouter/SearchRouterHelpers'; +import * as CreateNavigationSuggestions from '@components/Search/SearchRouter/useCreateNavigationSuggestions'; import useNavigationSuggestions, {buildSpendNavigationItems, buildTopLevelNavigationItems} from '@components/Search/SearchRouter/useNavigationSuggestions'; import {setSearchContext} from '@libs/actions/Search'; @@ -37,6 +38,12 @@ jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}), })); +jest.mock('@components/Search/SearchRouter/useCreateNavigationSuggestions', () => ({ + __esModule: true, + ...jest.requireActual('@components/Search/SearchRouter/useCreateNavigationSuggestions'), + default: () => [], +})); + jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: () => mockUseMemoizedLazyExpensifyIcons(), })); @@ -81,6 +88,8 @@ jest.mock('@libs/actions/Search', () => ({ jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, default: { + dismissModal: jest.fn(), + isTopmostRouteModalScreen: jest.fn(() => false), navigate: jest.fn(), }, })); @@ -288,6 +297,63 @@ describe('top-level Search Router navigation source', () => { }); }); +describe('Create Search Router navigation source', () => { + const createAction = jest.fn(); + const createItems: CreateNavigationSuggestions.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 = CreateNavigationSuggestions.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 = CreateNavigationSuggestions.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); + + CreateNavigationSuggestions.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); + + CreateNavigationSuggestions.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); + }); +}); + describe('Spend Search Router navigation source', () => { beforeEach(() => { jest.clearAllMocks(); 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}); + }); +});