Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 8 additions & 8 deletions src/components/Search/SearchRouter/SearchRouterHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>) {
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<string | undefined>) {
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<T extends NavigationSuggestionSourceItem>(items: T[], localeCompare: LocaleContextProps['localeCompare']): T[] {
Expand Down
207 changes: 207 additions & 0 deletions src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* Builds the Create/FAB navigation suggestions shown in the Search Router.
*/
import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem';
Comment thread
nabi-ebrahimi marked this conversation as resolved.

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

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<OnyxTypes.Policy>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {
selector: (policies: OnyxCollection<OnyxTypes.Policy>) => getGroupPoliciesWhereReportCanBeCreated(policies, isSubmit2026BetaEnabled, session?.email),
Comment thread
nabi-ebrahimi marked this conversation as resolved.
});
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 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});
Comment thread
nabi-ebrahimi marked this conversation as resolved.
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};
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();
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);
}

export default useNavigationSuggestions;
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,
}),
useNavigationState: () => ({
routes: [],
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/SearchRouterNavigationTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,6 +38,12 @@ jest.mock('@components/Search/SearchContext', () => ({
useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}),
}));

jest.mock('@components/Search/SearchRouter/useCreateNavigationSuggestions', () => ({
__esModule: true,
...jest.requireActual<typeof CreateNavigationSuggestions>('@components/Search/SearchRouter/useCreateNavigationSuggestions'),
default: () => [],
}));
Comment thread
nabi-ebrahimi marked this conversation as resolved.

jest.mock('@hooks/useLazyAsset', () => ({
useMemoizedLazyExpensifyIcons: () => mockUseMemoizedLazyExpensifyIcons(),
}));
Expand Down Expand Up @@ -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(),
},
}));
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading