Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7bf8951
Add Create actions to Search Router navigation
nabi-ebrahimi Jul 27, 2026
88d4fd6
Merge branch 'main' into 92752-search-router-create-actions
nabi-ebrahimi Jul 30, 2026
8ac691f
Fix Create navigation hook mock in Search Router tests
nabi-ebrahimi Jul 30, 2026
3ad4660
Fix Search Router performance test navigation mock
nabi-ebrahimi Jul 30, 2026
455e6cc
Merge branch 'main' into 92752-search-router-create-actions
nabi-ebrahimi Jul 31, 2026
1ca043d
Add Create navigation hook file header
nabi-ebrahimi Jul 31, 2026
b02b3a9
Replace Create policy limit magic value
nabi-ebrahimi Jul 31, 2026
44efed7
Use eligible report policies for Create suggestions
nabi-ebrahimi Jul 31, 2026
90e4f11
Remove unsafe Create policy assertion
nabi-ebrahimi Jul 31, 2026
19da82e
Document Create report policy eligibility
nabi-ebrahimi Jul 31, 2026
4a0f22a
Document Create report navigation ordering
nabi-ebrahimi Jul 31, 2026
68ed8d4
Document Create modal replacement behavior
nabi-ebrahimi Jul 31, 2026
413d46a
Add Create navigation hook coverage
nabi-ebrahimi Jul 31, 2026
c046aa6
Merge branch 'main' into 92752-search-router-create-actions
nabi-ebrahimi Aug 4, 2026
e50c880
Merge branch 'main' into 92752-search-router-create-actions
nabi-ebrahimi Aug 6, 2026
3dc5b82
Fix Create report navigation from native Search Router
nabi-ebrahimi Aug 6, 2026
fbdbad5
Remove redundant violation subscription from Create suggestions
nabi-ebrahimi Aug 6, 2026
7ca67db
Remove duplicate session subscription from Create suggestions
nabi-ebrahimi Aug 6, 2026
13ce056
Remove redundant navigation value normalization
nabi-ebrahimi Aug 6, 2026
0ad477b
Preserve Create navigation source item type
nabi-ebrahimi Aug 6, 2026
cc7f946
Document Create report eligibility behavior
nabi-ebrahimi Aug 6, 2026
fb6b852
Add Create suggestion source integration coverage
nabi-ebrahimi Aug 6, 2026
2fd1bcc
Add localized aliases for Create suggestions
nabi-ebrahimi Aug 6, 2026
463d3e6
Remove stale Create suggestions violation mock
nabi-ebrahimi Aug 6, 2026
4a10180
Defer Create report confirmation scan in Search Router
nabi-ebrahimi Aug 6, 2026
6050523
Remove duplicate policy subscription from Create suggestions
nabi-ebrahimi Aug 6, 2026
69b620c
Remove redundant navigation match trimming
nabi-ebrahimi Aug 6, 2026
91a0be8
Remove redundant Create whitespace test
nabi-ebrahimi Aug 6, 2026
b6328dd
Merge branch 'main' into 92752-search-router-create-actions
nabi-ebrahimi Aug 7, 2026
af2b8fc
Merge branch 'main' into 92752-search-router-create-actions
nabi-ebrahimi Aug 12, 2026
789c795
Pass currency decimals to Create report action
nabi-ebrahimi Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* Builds the Create/FAB navigation suggestions shown in the Search Router.
*/
import useCreateReport from '@hooks/useCreateReport';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
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} 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} from '@src/selectors/Session';
import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft';
import type IconAsset from '@src/types/utils/IconAsset';

import {useState} from 'react';

import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers';

import {isNavigationIntentOnlyQuery, matchesNavigationQuery, stripNavigationIntentPrefix} from './SearchRouterHelpers';

type CreateNavigationItem = {
visible: boolean;
text: string;
icon: IconAsset;
action: () => void;
keyForList: string;
matchTerms?: string[];
};

function buildCreateNavigationItems(items: CreateNavigationItem[]): NavigationSuggestionSourceItem[] {
return items
.filter((item) => item.visible)
.map(({text, icon, action, keyForList, matchTerms}) => ({
text,
singleIcon: icon,
action,
keyForList,
matchTerms: 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});
}
Comment thread
nabi-ebrahimi marked this conversation as resolved.

function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceItem[] {
const {translate} = useLocalize();
const icons = useMemoizedLazyExpensifyIcons(['Coins', 'Receipt', 'Cash', 'Transfer', 'MoneyCircle', 'Location', 'Document', 'ChatBubble', 'InvoiceGeneric', 'NewWorkspace']);
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const {getCurrencyDecimals} = useCurrencyListActions();
const {isBetaEnabled} = usePermissions();
const isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026);
const {isOffline} = useNetwork();
const {isRestrictedPolicyCreation} = usePreferredPolicy();
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
Comment thread
nabi-ebrahimi marked this conversation as resolved.
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 [allBetas] = useOnyx(ONYXKEYS.BETAS);
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`);
// Match Quick Creation and Search actions by using shared report eligibility rules. Unlike the FAB's
// expense-chat filter, this intentionally includes Submit workspaces only when the beta is enabled.
const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(allPolicies ?? null, isSubmit2026BetaEnabled, sessionEmail);
const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector});
const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP);

const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy([...groupPoliciesWithChatEnabled], activePolicy);
Comment thread
nabi-ebrahimi marked this conversation as resolved.
const isInvoiceVisible = canSendInvoice(allPolicies ?? null, sessionEmail);

const createExpenseMatchTerms = [translate('iou.createExpense'), translate('iou.addExpense'), translate('homePage.gettingStartedSection.createExpense')];
const createReportMatchTerms = [translate('report.newReport.createReport')];
const trackDistanceMatchTerms = [translate('iou.trackDistance')];
const chatMatchTerms = [translate('sidebarScreen.fabNewChat'), `${translate('common.new')} ${translate('common.chat')}`];
const invoiceMatchTerms = [translate('workspace.invoices.sendInvoice')];
const workspaceMatchTerms = [translate('workspace.new.newWorkspace'), translate('onboarding.workspace.createWorkspace'), translate('homePage.gettingStartedSection.createWorkspace')];
const matchQuery = stripNavigationIntentPrefix(query);
const shouldPrepareCreateReport =
isNavigationIntentOnlyQuery(query) ||
[createExpenseMatchTerms, createReportMatchTerms, trackDistanceMatchTerms, chatMatchTerms, invoiceMatchTerms, workspaceMatchTerms].some((matchTerms) =>
matchesNavigationQuery(matchQuery, ...matchTerms),
);

const {createReport, isVisible: isCreateReportVisible} = useCreateReport({
onCreateReport: (shouldDismissEmptyReportsConfirmation?: boolean) => {
if (!defaultChatEnabledPolicy?.id) {
return;
}

// Read this after Search Router closes so native resolves the underlying route instead of the router screen itself.
const isReportInSearch = isOnSearchMoneyRequestReportPage();
if (isReportInSearch) {
clearLastSearchParams();
}

const {reportID: createdReportID} = createNewReport(
currentUserPersonalDetails,
false,
Comment thread
nabi-ebrahimi marked this conversation as resolved.
isBetaEnabled(CONST.BETAS.ASAP_SUBMIT),
defaultChatEnabledPolicy,
allBetas,
isTrackIntentUser,
getCurrencyDecimals,
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});
Comment thread
nabi-ebrahimi marked this conversation as resolved.
Navigation.setNavigationActionToMicrotaskQueue(() => {
Navigation.navigate(getCreateReportRoute({reportID: createdReportID}), {forceReplace: isReportInSearch});
});
},
groupPoliciesWithChatEnabled,
onNavigateToWorkspaceSelection: () => navigateToCreateReportWorkspaceSelection({forceReplace: isOnSearchMoneyRequestReportPage()}),
shouldHandleNavigationBack: false,
shouldSkipEmptyReportConfirmation: !shouldPrepareCreateReport,
});
Comment thread
nabi-ebrahimi marked this conversation as resolved.

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),
matchTerms: createExpenseMatchTerms,
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,
matchTerms: createReportMatchTerms,
action: () => replaceTopmostModalWithAction(createReport),
keyForList: 'create_report',
},
{
visible: true,
text: translate('iou.trackDistance'),
icon: icons.Location,
matchTerms: trackDistanceMatchTerms,
action: () =>
replaceTopmostModalWithAction(() => {
interceptAnonymousUser(() => {
startDistanceRequest(CONST.IOU.TYPE.CREATE, reportID, draftTransactionIDs, lastDistanceExpenseType, undefined, undefined, true);
});
}),
keyForList: 'create_trackDistance',
},
{
visible: true,
text: translate('sidebarScreen.fabNewChat'),
Comment thread
nabi-ebrahimi marked this conversation as resolved.
icon: icons.ChatBubble,
matchTerms: chatMatchTerms,
action: () => replaceTopmostModalWithAction(() => interceptAnonymousUser(startNewChat)),
keyForList: 'create_chat',
},
{
visible: isInvoiceVisible,
text: translate('workspace.invoices.sendInvoice'),
icon: icons.InvoiceGeneric,
matchTerms: invoiceMatchTerms,
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,
matchTerms: workspaceMatchTerms,
action: () =>
replaceTopmostModalWithAction(() => {
interceptAnonymousUser(() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CONFIRMATION.path)));
}),
keyForList: 'create_workspace',
},
]);
}

export default useCreateNavigationSuggestions;
export {buildCreateNavigationItems, replaceTopmostModalWithAction};
export type {CreateNavigationItem};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<SearchTypeMenuItem['icon'], IconAsset>;
Expand Down Expand Up @@ -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(query);
const {clearSelectedTransactions} = useSearchSelectionActions();
const {typeMenuSections} = useSearchTypeMenuSections(undefined, shouldWatchForApprovals);

Expand Down Expand Up @@ -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);
Comment thread
nabi-ebrahimi marked this conversation as resolved.
}

export default useNavigationSuggestions;
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useCreateReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type UseCreateReportParams = {
onNavigateToWorkspaceSelection?: () => void;
/** Whether the empty-report confirmation modal should push a history entry so browser-back dismisses it (default: true) */
shouldHandleNavigationBack?: boolean;
/** Whether to skip the empty-report confirmation scan until the entry point is relevant */
shouldSkipEmptyReportConfirmation?: boolean;
};

type UseCreateReportResult = {
Expand All @@ -53,6 +55,7 @@ export default function useCreateReport({
groupPoliciesWithChatEnabled,
onNavigateToWorkspaceSelection,
shouldHandleNavigationBack = true,
shouldSkipEmptyReportConfirmation = false,
}: UseCreateReportParams): UseCreateReportResult {
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`);
Expand All @@ -72,7 +75,7 @@ export default function useCreateReport({
const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array<OnyxEntry<OnyxTypes.Policy>>, activePolicy);
const defaultChatEnabledPolicyID = defaultChatEnabledPolicy?.id;

const shouldShowEmptyReportConfirmation = useShouldShowEmptyReportConfirmation(defaultChatEnabledPolicyID);
const shouldShowEmptyReportConfirmation = useShouldShowEmptyReportConfirmation(defaultChatEnabledPolicyID, shouldSkipEmptyReportConfirmation);

const {openCreateReportConfirmation} = useCreateEmptyReportConfirmation({
policyID: defaultChatEnabledPolicyID,
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/useShouldShowEmptyReportConfirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {Transaction} from '@src/types/onyx';
import type {OnyxCollection} from 'react-native-onyx';

import {accountIDSelector} from '@selectors/Session';
import {useCallback} from 'react';

import useOnyx from './useOnyx';

Expand All @@ -26,7 +27,16 @@ function reportIDsWithActiveTransactionsSelector(transactions: OnyxCollection<Tr
function useShouldShowEmptyReportConfirmation(policyID: string | undefined, skip?: boolean): boolean {
const [hasDismissedConfirmation] = useOnyx(ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED);
const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
const [reportIDsWithActiveTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: reportIDsWithActiveTransactionsSelector});
const activeTransactionsSelector = useCallback(
(transactions: OnyxCollection<Transaction>) => {
if (skip) {
return {};
}
return reportIDsWithActiveTransactionsSelector(transactions);
},
[skip],
);
const [reportIDsWithActiveTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: activeTransactionsSelector});

const [hasEmptyReport = false] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {
selector: (reports: Parameters<typeof hasEmptyReportsForPolicy>[0]) => {
Expand Down
6 changes: 5 additions & 1 deletion tests/perf-test/SearchRouter.perf-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -81,6 +84,7 @@ jest.mock('@react-navigation/native', () => {
isReady: () => jest.fn(),
getCurrentRoute: () => jest.fn(),
getState: () => jest.fn(),
getRootState: () => undefined,
Comment thread
nabi-ebrahimi marked this conversation as resolved.
}),
useNavigationState: () => ({
routes: [],
Expand Down
Loading
Loading