diff --git a/contributingGuides/features/Search.md b/contributingGuides/features/Search.md
new file mode 100644
index 000000000000..2594504728a1
--- /dev/null
+++ b/contributingGuides/features/Search.md
@@ -0,0 +1,54 @@
+---
+title: Search Functionality in New Expensify
+description: Learn how to effectively use the powerful search feature in New Expensify to find and filter your financial data quickly and efficiently.
+---
+
+
+The Search functionality in New Expensify provides a powerful way to locate and filter financial data.
+With advanced autocomplete suggestions and predefined filters, you can quickly find specific expenses, reports,
+invoices, and more across the platform.
+
+## Main Uses
+
+- **Locate specific transactions** - Quickly find expenses, invoices, or other financial data using keywords or filters.
+- **Filter by status** - Easily view items based on their current state (Drafts, Outstanding, Approved, etc.).
+- **Save frequent searches** - Store commonly used search parameters for quick access in the future.
+
+## Search Components
+The search functionality in Expensify consists of several key components:
+
+- **Search input** - Located at the top of the Reports view for entering search terms.
+- **Left-hand navigation (LHN)** - Allows switching between different data types and saved searches.
+- **Predefined filters** - Quick-access filters at the top of each list view.
+- **Autocomplete modal** - Suggestions that appear as you type in the search input.
+- **Results list** - The formatted list of search results displayed below the filters, showing matching items based on
+your search criteria and selected filters.
+
+# How Search Works
+## Basic Navigation
+1. When you select a data type from the LHN (Expenses, Expense Reports, Chats, Invoices, or Trips), the system calls the `/Search` endpoint to display results.
+2. Selecting a predefined filter (All, Drafts, Outstanding, etc.) also triggers the `/Search` endpoint with the appropriate parameters.
+
+## Using Predefined Filters
+Each data type offers specific predefined filters for quick access:
+
+1. Navigate to the desired data type view (e.g., Expenses) via the LHN.
+2. At the top of the list, select one of the predefined filters:
+ - **All** - Shows all items (default selection)
+ - **Drafts** - Items not yet submitted
+ - **Outstanding** - Items awaiting action
+ - **Approved** - Items that have received approval
+ - **Done** - Completed items
+ - **Paid** - Items that have been reimbursed or paid
+
+Note: Available filters may vary depending on the data type selected.
+
+## Using the Search Input
+The search input provides powerful functionality:
+1. Click in the search field at the top of the Reports view.
+2. Begin typing your search term.
+3. The autocomplete modal appears with suggestions:
+ - The first option always shows your exact search text with a magnifying glass icon
+ - Additional suggestions based on your input from `/SearchForReports` and Onyx data
+4. Select first suggestion (magnifying glass icon with search text) or press Enter to execute the search (`/Search` endpoint call).
+5. Clicking on any other suggestion (retrieved from either `/SearchForReports` or Onyx data) directly opens the selected report by calling the `/OpenReport` endpoint.
diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx
index 9cc8bc9164df..db43dab8863f 100644
--- a/src/components/Search/SearchAutocompleteList.tsx
+++ b/src/components/Search/SearchAutocompleteList.tsx
@@ -17,7 +17,6 @@ import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import {searchInServer} from '@libs/actions/Report';
import {getCardFeedKey, getCardFeedNamesWithType} from '@libs/CardFeedUtils';
import {getCardDescription, isCard, isCardHiddenFromSearch, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils';
import memoize from '@libs/memoize';
@@ -35,7 +34,7 @@ import {
getQueryWithoutAutocompletedPart,
parseForAutocomplete,
} from '@libs/SearchAutocompleteUtils';
-import {buildSearchQueryJSON, buildUserReadableQueryString, sanitizeSearchValue, shouldHighlight} from '@libs/SearchQueryUtils';
+import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithoutFilters, sanitizeSearchValue, shouldHighlight} from '@libs/SearchQueryUtils';
import StringUtils from '@libs/StringUtils';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
@@ -57,6 +56,9 @@ type SearchAutocompleteListProps = {
/** Value of TextInput */
autocompleteQueryValue: string;
+ /** Callback to trigger search action * */
+ handleSearch: (value: string) => void;
+
/** An optional item to always display on the top of the router list */
searchQueryItem?: SearchQueryItem;
@@ -133,6 +135,7 @@ function SearchRouterItem(props: UserListItemProps
| SearchQueryList
function SearchAutocompleteList(
{
autocompleteQueryValue,
+ handleSearch,
searchQueryItem,
getAdditionalSections,
onListItemPress,
@@ -266,8 +269,13 @@ function SearchAutocompleteList(
}, [activeWorkspaceID, allPoliciesTags]);
const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
+ const [autocompleteParsedQuery, autocompleteQueryWithoutFilters] = useMemo(() => {
+ const parsedQuery = parseForAutocomplete(autocompleteQueryValue);
+ const queryWithoutFilters = getQueryWithoutFilters(autocompleteQueryValue);
+ return [parsedQuery, queryWithoutFilters];
+ }, [autocompleteQueryValue]);
+
const autocompleteSuggestions = useMemo(() => {
- const autocompleteParsedQuery = parseForAutocomplete(autocompleteQueryValue);
const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {};
const autocompleteKey = autocomplete?.key;
const autocompleteValue = autocomplete?.value ?? '';
@@ -451,7 +459,7 @@ function SearchAutocompleteList(
}
}
}, [
- autocompleteQueryValue,
+ autocompleteParsedQuery,
tagAutocompleteList,
recentTagsAutocompleteList,
categoryAutocompleteList,
@@ -514,8 +522,12 @@ function SearchAutocompleteList(
}, [autocompleteQueryValue, filterOptions, searchOptions]);
useEffect(() => {
- searchInServer(autocompleteQueryValue.trim());
- }, [autocompleteQueryValue]);
+ if (!handleSearch) {
+ return;
+ }
+
+ handleSearch(autocompleteQueryWithoutFilters);
+ }, [autocompleteQueryWithoutFilters, handleSearch]);
/* Sections generation */
const sections: Array> = [];
diff --git a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx
index 3e3c3f9d4259..62244fd382c2 100644
--- a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx
@@ -28,24 +28,25 @@ type SearchPageHeaderProps = {
hideSearchRouterList?: () => void;
onSearchRouterFocus?: () => void;
headerButtonsOptions: Array>;
+ handleSearch: (value: string) => void;
};
type SearchHeaderOptionValue = DeepValueOf | undefined;
-function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, headerButtonsOptions}: SearchPageHeaderProps) {
+function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, headerButtonsOptions, handleSearch}: SearchPageHeaderProps) {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {selectedTransactions} = useSearchContext();
- const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
+ const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE, {canBeMissing: true});
const personalDetails = usePersonalDetails();
- const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true});
const taxRates = getAllTaxRates();
- const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST);
- const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST);
+ const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: true});
+ const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: true});
const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList), [userCardList, workspaceCardFeeds]);
- const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST);
- const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
- const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
+ const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST, {canBeMissing: true});
+ const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {canBeMissing: true});
+ const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {canBeMissing: true});
const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});
@@ -85,6 +86,7 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS
searchName={searchName}
hideSearchRouterList={hideSearchRouterList}
inputRightComponent={InputRightComponent}
+ handleSearch={handleSearch}
/>
);
}
diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx
index b2729852f902..16dcb0177e9d 100644
--- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx
+++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx
@@ -50,9 +50,10 @@ type SearchPageHeaderInputProps = {
onSearchRouterFocus?: () => void;
searchName?: string;
inputRightComponent: React.ReactNode;
+ handleSearch: (value: string) => void;
};
-function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, searchName, inputRightComponent}: SearchPageHeaderInputProps) {
+function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, searchName, inputRightComponent, handleSearch}: SearchPageHeaderInputProps) {
const {translate} = useLocalize();
const [showPopupButton, setShowPopupButton] = useState(true);
const styles = useThemeStyles();
@@ -83,9 +84,14 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
const [isAutocompleteListVisible, setIsAutocompleteListVisible] = useState(false);
const listRef = useRef(null);
const textInputRef = useRef(null);
+ const hasMountedRef = useRef(false);
const isFocused = useIsFocused();
const {registerSearchPageInput} = useSearchRouterContext();
+ useEffect(() => {
+ hasMountedRef.current = true;
+ }, []);
+
// useEffect for blurring TextInput when we cancel SearchRouter interaction on narrow layout
useEffect(() => {
if (!displayNarrowHeader || !!searchRouterListVisible || !textInputRef.current || !textInputRef.current.isFocused()) {
@@ -132,6 +138,16 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const handleSearchAction = useCallback(
+ (value: string) => {
+ // Skip calling handleSearch on the initial mount
+ if (!hasMountedRef.current) {
+ return;
+ }
+ handleSearch(value);
+ },
+ [handleSearch],
+ );
const onSearchQueryChange = useCallback(
(userQuery: string) => {
const singleLineUserQuery = StringUtils.lineBreaksToSpaces(userQuery, true);
@@ -279,6 +295,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
;
currentSearchResults?: SearchResults;
lastNonEmptySearchResults?: SearchResults;
+ handleSearch: (value: SearchParams) => void;
};
function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] {
@@ -127,7 +128,7 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact
};
}
-function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onSearchListScroll, contentContainerStyle}: SearchProps) {
+function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onSearchListScroll, contentContainerStyle, handleSearch}: SearchProps) {
const {isOffline} = useNetwork();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const styles = useThemeStyles();
@@ -201,8 +202,8 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS
return;
}
- search({queryJSON, offset});
- }, [isOffline, offset, queryJSON]);
+ handleSearch({queryJSON, offset});
+ }, [handleSearch, isOffline, offset, queryJSON]);
const {newSearchResultKey, handleSelectionListScroll} = useSearchHighlightAndScroll({
searchResults,
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 335b8cf103b5..a10bbd5f8733 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -154,6 +154,11 @@ type SearchAutocompleteQueryRange = {
value: string;
};
+type SearchParams = {
+ queryJSON: SearchQueryJSON;
+ offset: number;
+};
+
export type {
SelectedTransactionInfo,
SelectedTransactions,
@@ -178,6 +183,7 @@ export type {
SearchAutocompleteResult,
PaymentData,
SearchAutocompleteQueryRange,
+ SearchParams,
TableColumnSize,
SearchGroupBy,
};
diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts
index ba1c985eaef0..e18fa83c8c8a 100644
--- a/src/libs/SearchQueryUtils.ts
+++ b/src/libs/SearchQueryUtils.ts
@@ -827,6 +827,25 @@ function getCurrentSearchQueryJSON() {
return queryJSON;
}
+/**
+ * Extracts the query text without the filter parts.
+ * This is used to determine if a user's core search terms have changed,
+ * ignoring any filter modifications.
+ *
+ * @param searchQuery - The complete search query string
+ * @returns The query without filters (core search terms only)
+ */
+function getQueryWithoutFilters(searchQuery: string) {
+ const queryJSON = buildSearchQueryJSON(searchQuery);
+ if (!queryJSON) {
+ return '';
+ }
+
+ const keywordFilter = queryJSON.flatFilters.find((filter) => filter.key === 'keyword');
+
+ return keywordFilter?.filters.map((filter) => filter.value).join(' ') ?? '';
+}
+
/**
* Converts a filter key from old naming (camelCase) to user friendly naming (kebab-case).
*
@@ -869,6 +888,7 @@ export {
sanitizeSearchValue,
getQueryWithUpdatedValues,
getCurrentSearchQueryJSON,
+ getQueryWithoutFilters,
getUserFriendlyKey,
isDefaultExpensesQuery,
shouldHighlight,
diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx
index 111d072d6a0f..67b978b97ed4 100644
--- a/src/pages/Search/SearchPage.tsx
+++ b/src/pages/Search/SearchPage.tsx
@@ -15,7 +15,7 @@ import {useSearchContext} from '@components/Search/SearchContext';
import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/SearchPageHeader';
import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader';
import SearchStatusBar from '@components/Search/SearchPageHeader/SearchStatusBar';
-import type {PaymentData} from '@components/Search/types';
+import type {PaymentData, SearchParams} from '@components/Search/types';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useLocalize from '@hooks/useLocalize';
@@ -25,6 +25,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
+import {searchInServer} from '@libs/actions/Report';
import {
approveMoneyRequestOnSearch,
deleteMoneyRequestOnSearch,
@@ -32,6 +33,7 @@ import {
getLastPolicyPaymentMethod,
payMoneyRequestOnSearch,
queueExportSearchItemsToCSV,
+ search,
unholdMoneyRequestOnSearch,
} from '@libs/actions/Search';
import Navigation from '@libs/Navigation/Navigation';
@@ -395,6 +397,14 @@ function SearchPage({route}: SearchPageProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const handleSearchAction = useCallback((value: SearchParams | string) => {
+ if (typeof value === 'string') {
+ searchInServer(value);
+ } else {
+ search(value);
+ }
+ }, []);
+
if (shouldUseNarrowLayout) {
return (
<>
@@ -487,6 +497,7 @@ function SearchPage({route}: SearchPageProps) {
diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx
index 8d938c9b269f..3993df9bf495 100644
--- a/src/pages/Search/SearchPageNarrow.tsx
+++ b/src/pages/Search/SearchPageNarrow.tsx
@@ -14,7 +14,7 @@ import {useSearchContext} from '@components/Search/SearchContext';
import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader';
import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/SearchPageHeader';
import SearchStatusBar from '@components/Search/SearchPageHeader/SearchStatusBar';
-import type {SearchQueryJSON} from '@components/Search/types';
+import type {SearchParams, SearchQueryJSON} from '@components/Search/types';
import useHandleBackButton from '@hooks/useHandleBackButton';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -28,6 +28,8 @@ import Navigation from '@libs/Navigation/Navigation';
import {buildCannedSearchQuery, isCannedSearchQuery} from '@libs/SearchQueryUtils';
import {isSearchDataLoaded} from '@libs/SearchUIUtils';
import variables from '@styles/variables';
+import {searchInServer} from '@userActions/Report';
+import {search} from '@userActions/Search';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {SearchResults} from '@src/types/onyx';
@@ -107,6 +109,14 @@ function SearchPageNarrow({queryJSON, searchName, headerButtonsOptions, currentS
Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()}));
}, [searchRouterListVisible]);
+ const handleSearchAction = useCallback((value: SearchParams | string) => {
+ if (typeof value === 'string') {
+ searchInServer(value);
+ } else {
+ search(value);
+ }
+ }, []);
+
if (!queryJSON) {
return (
@@ -191,6 +202,7 @@ function SearchPageNarrow({queryJSON, searchName, headerButtonsOptions, currentS
queryJSON={queryJSON}
searchName={searchName}
headerButtonsOptions={headerButtonsOptions}
+ handleSearch={handleSearchAction}
/>
>
)}
@@ -203,6 +215,7 @@ function SearchPageNarrow({queryJSON, searchName, headerButtonsOptions, currentS
queryJSON={queryJSON}
onSearchListScroll={scrollHandler}
contentContainerStyle={!selectionMode?.isEnabled ? styles.searchListContentContainerStyles : undefined}
+ handleSearch={handleSearchAction}
/>
)}